Files
pdfcraft/crates/automation/src/forms.rs
T
Erkan DogruandFredApps f6c59c0b93 fix: integrate persistent AcroForm script workflows with native prompts and appearance writeback (#5699)
* AcroForm: integrate persistent timesheet script workflows

* AcroForm: address review caching, worker stacks and live metadata

* M6: address second review of AcroForm script workflows

* M6: handle top-level ASI boundaries and verify cyclic calculation results

* M6: bound script workers and isolate pending completions across edits

* M6: make form resets visible before subsequent script reads

* M6: include calculated dependents in reset comparison baselines

* M6: bound reset writeback work before native completion

* M6: charge expanded reset selections and field name bytes

* M6: bound string conversion bytes and charge reset widgets

* M6: preserve unread defaults and bound prompt text conversion

* M6: bound reset calls early and preserve calculated field identity

* AcroForm: address grouped calculations, field identity and widget ownership

* AcroForm: retire field instances and address native review edge cases

* AcroForm: route comparison Save As and Print to the source document

* M6: dispatch text widget mouse-up scripts

* M6: honor fit bounds for form button icons

* AcroForm: bound calculation work and preserve reset and save integrity

* Harden AcroForm script requests and worker recovery

* Keep form callbacks and focus consistent across edits

* AcroForm: preserve reset ordering and bound edit callbacks

* Fix reset appearance ordering and XFA focus after row removal

* Keep XFA focus indexes and stopped reset appearances consistent

* Document form script execution and reset helpers

* Keep script workers bounded and reject partial callback edits

* Keep form events available after a document script reaches VM limits

* Keep main's first-value Simple calculation and restore the calculateNow helper test

AFSimple_Calculate (JavaScript) and the native Simple calculation count each field's first
value again, as #5304 landed them; summing every selection of a multiple-selection list is
a separate behaviour change. Restores the document-helper calculateNow test and the
AFSimple_Calculate doc comment, and adds native and JavaScript multiple-selection cases.

* Patch the callback field snapshot in place instead of rebuilding it

Each field callback rebuilt all field states (3,129 on the benchmark form), and the
retained realm held the previous snapshot, so stamping instance tokens copied it again.
The runner now keeps its last snapshot and patches only the values and flags that
changed; ScriptSession::stamp_fields stamps it before dispatch, the worker copies only
when a token changes, and the realm drops the job's snapshot when the job ends (field
identity is checked against the cached keys instead).

* An oversized job script is that script's error, not a refused edit

Realm::run refused a script too long or too deep to run as an execution refusal, which
made the engine reject the whole edit (setting a 300 KiB calculation script failed). As on
main it is now a plain script error that keeps the value; the realm is not touched.

* Read a leading #! line as a comment in the chain scan; follow main's refusal and conditional limits in tests

The nesting guard from #5079 now checks brackets, prefixes and conditionals; the document
worker's preflight still bounds arrow-function and right-associative chains and skips a
leading #! line as the guard does. A refused script is an error, not a refused edit, so
the preflight test now checks the edit goes through with the error reported.

---------

Co-authored-by: FredApps <153173854+FredApps@users.noreply.github.com>
2026-11-11 08:42:04 +01:00

911 lines
47 KiB
Rust

//! Form tools: list fields, fill them (several at once, one undo step), clear the form, and
//! prepare a form (add fields one at a time or as a table, change and delete them).
use pdfcraft_engine::form_scripts::{CalcOp, Calculate, Format, NOTATION_NOT_STORABLE, Validate, notation_storable};
use pdfcraft_engine::{CalculationIssue, Edit, FieldProps, FieldValue, FormField, FormFieldKind, NewField, field_flags};
use serde_json::{Value, json};
use crate::{Args, Automation, Result, ToolError, failed};
fn bad(m: impl Into<String>) -> ToolError {
ToolError::InvalidArgs(m.into())
}
/// `{"type": "number", "decimals": 2, "currency": "$"}`, `{"type": "date", "pattern": "mm/dd/yyyy"}`,
/// `{"type": "phone"}`, `{"type": "mask", "mask": "AA-9999"}`, `"none"`…
fn format_arg(v: &Value) -> Result<Format> {
if v.as_str() == Some("none") {
return Ok(Format::None);
}
let o = v.as_object().ok_or_else(|| bad("format must be an object such as {\"type\": \"number\", \"decimals\": 2}, or \"none\""))?;
let num = |k: &str, d: u64| o.get(k).and_then(Value::as_u64).unwrap_or(d).min(255) as u8;
let s = |k: &str| o.get(k).and_then(Value::as_str).map(str::to_owned);
Ok(match o.get("type").and_then(Value::as_str).unwrap_or("") {
"none" => Format::None,
"number" => Format::Number {
decimals: num("decimals", 2),
sep: num("separator", 0).min(4),
neg: num("negative", 0).min(3),
currency: s("currency").unwrap_or_default(),
prepend: !o.get("currency_after").and_then(Value::as_bool).unwrap_or(false),
},
"percent" => Format::Percent { decimals: num("decimals", 2), sep: num("separator", 0).min(4) },
"date" => Format::Date(s("pattern").unwrap_or_else(|| "mm/dd/yyyy".into())),
"time" => Format::Time(s("pattern").unwrap_or_else(|| "HH:MM".into())),
"zip" => Format::Special(0),
"zip4" => Format::Special(1),
"phone" => Format::Special(2),
"ssn" => Format::Special(3),
"mask" => Format::Mask(s("mask").ok_or_else(|| bad("mask needs \"mask\""))?),
t => return Err(bad(format!("unknown format type {t:?} (none, number, percent, date, time, zip, zip4, phone, ssn, mask)"))),
})
}
/// `{"border": "#FF0000" | "none", "fill": …, "width": 1-3, "style": "solid"|…, "text_color": …,
/// "font": "helvetica"|"times"|"courier"}`, changing only what is given.
fn look_arg(v: &Value) -> Result<pdfcraft_engine::FieldLookPatch> {
use pdfcraft_engine::{BorderStyle, FieldFont, FieldLookPatch};
let o = v.as_object().ok_or_else(|| bad("appearance must be an object"))?;
for key in o.keys() {
if !["border", "fill", "text_color", "width", "style", "font"].contains(&key.as_str()) {
return Err(bad(format!("unknown appearance property {key:?}")));
}
}
let colour = |k: &str| -> Result<Option<Option<[f64; 3]>>> {
match o.get(k) {
None => Ok(None),
Some(Value::String(s)) if s == "none" => Ok(Some(None)),
Some(Value::String(s)) => Ok(Some(Some(crate::comments::parse_color(s)?))),
Some(_) => Err(bad(format!("{k} must be a colour or \"none\""))),
}
};
let string =
|k: &str| -> Result<Option<&str>> { o.get(k).map(|v| v.as_str().ok_or_else(|| bad(format!("appearance.{k} must be a string")))).transpose() };
Ok(FieldLookPatch {
border: colour("border")?,
fill: colour("fill")?,
text: colour("text_color")?.map(|c| c.unwrap_or([0.0; 3])),
width: o
.get("width")
.map(|v| {
v.as_f64()
.filter(|v| v.is_finite() && (0.0..=12.0).contains(v))
.ok_or_else(|| bad("appearance.width must be between 0 and 12 points"))
})
.transpose()?,
style: string("style")?
.map(|s| match s {
"solid" => Ok(BorderStyle::Solid),
"dashed" => Ok(BorderStyle::Dashed),
"beveled" => Ok(BorderStyle::Beveled),
"inset" => Ok(BorderStyle::Inset),
"underline" => Ok(BorderStyle::Underline),
x => Err(bad(format!("unknown style {x:?}"))),
})
.transpose()?,
font: string("font")?
.map(|s| match s {
"helvetica" => Ok(FieldFont::Helvetica),
"times" => Ok(FieldFont::Times),
"courier" => Ok(FieldFont::Courier),
x => Err(bad(format!("unknown font {x:?} (helvetica, times, courier)"))),
})
.transpose()?,
})
}
/// `{"min": 0, "max": 100}` or `"none"`. A null bound is no bound, as `form_fields` shows it.
fn validate_arg(v: &Value) -> Result<Validate> {
if v.as_str() == Some("none") {
return Ok(Validate::None);
}
let o = v.as_object().ok_or_else(|| bad("validate must be {\"min\": …, \"max\": …} or \"none\""))?;
if let Some(k) = o.keys().find(|k| !matches!(k.as_str(), "min" | "max")) {
return Err(bad(format!("unknown key {k:?} in validate (min, max)")));
}
let bound = |k: &str| match o.get(k) {
None | Some(Value::Null) => Ok(None),
Some(x) => x.as_f64().map(Some).ok_or_else(|| bad(format!("validate {k} must be a number"))),
};
let (min, max) = (bound("min")?, bound("max")?);
if min.is_none() && max.is_none() {
return Err(bad("validate needs min, max or both"));
}
Ok(Validate::Range { min, max })
}
/// `{"op": "sum", "fields": ["a", "b"]}`, `{"notation": "Price * Qty"}` or `"none"`.
fn calculate_arg(v: &Value) -> Result<Calculate> {
if v.as_str() == Some("none") {
return Ok(Calculate::None);
}
let o = v.as_object().ok_or_else(|| bad("calculate must be {\"op\": …, \"fields\": […]}, {\"notation\": …} or \"none\""))?;
// Before anything else is read, so a script given beside a notation isn't dropped unnoticed.
if o.contains_key("script") {
return Err(bad("a custom calculate script is set with form_set_script (event: calculate)"));
}
if let Some(k) = o.keys().find(|k| !matches!(k.as_str(), "op" | "fields" | "notation")) {
return Err(bad(format!("unknown key {k:?} in calculate (op and fields, or notation)")));
}
// A null is no value, as for the tool's own arguments.
let get = |k: &str| o.get(k).filter(|v| !v.is_null());
if let Some(n) = get("notation") {
if get("op").is_some() || get("fields").is_some() {
return Err(bad("calculate takes a notation, or op and fields, not both"));
}
let n = n.as_str().ok_or_else(|| bad("notation must be a string such as \"Price * Qty\""))?;
// Refused rather than stored as a different formula.
if !notation_storable(n) {
return Err(bad(NOTATION_NOT_STORABLE));
}
return Ok(Calculate::Notation(n.to_string()));
}
let op = get("op").ok_or_else(|| bad("calculate needs op and fields, or a notation"))?;
let op = match op.as_str().ok_or_else(|| bad("op must be one of sum, product, average, min, max"))? {
"minimum" => CalcOp::Minimum,
"maximum" => CalcOp::Maximum,
x => {
CalcOp::ALL.into_iter().find(|&op| op_name(op) == x).ok_or_else(|| bad(format!("unknown op {x:?} (sum, product, average, min, max)")))?
}
};
let fields = get("fields").and_then(Value::as_array).ok_or_else(|| bad("calculate fields must be a list of field names"))?;
let fields: Vec<String> = fields
.iter()
.map(|x| x.as_str().map(str::to_owned).ok_or_else(|| bad("calculate fields must be field names (strings)")))
.collect::<Result<_>>()?;
if fields.is_empty() {
return Err(bad("calculate needs fields"));
}
Ok(Calculate::Simple { op, fields })
}
/// An operation's name in `calculate`, as `form_set_props` takes it and the listings show it.
fn op_name(op: CalcOp) -> &'static str {
match op {
CalcOp::Sum => "sum",
CalcOp::Product => "product",
CalcOp::Average => "average",
CalcOp::Minimum => "min",
CalcOp::Maximum => "max",
}
}
/// A field's calculation as `form_fields` and `form_calculation_order` show it, in
/// `form_set_props`'s shape, or `{"script": …}` for a custom Calculate script; `None` without one.
fn calculate_json(f: &FormField) -> Option<Value> {
match (&f.actions.calculate, &f.actions.scripts.calculate) {
(Calculate::Simple { op, fields }, _) => Some(json!({ "op": op_name(*op), "fields": fields })),
(Calculate::Notation(n), _) => Some(json!({ "notation": n })),
(Calculate::Custom(js), _) | (Calculate::None, Some(js)) => Some(json!({ "script": js })),
(Calculate::None, None) => None,
}
}
fn format_json(f: &Format) -> Value {
match f {
Format::None => Value::Null,
Format::Number { decimals, currency, .. } => json!({ "type": "number", "decimals": decimals, "currency": currency }),
Format::Percent { decimals, .. } => json!({ "type": "percent", "decimals": decimals }),
Format::Date(p) => json!({ "type": "date", "pattern": p }),
Format::Time(p) => json!({ "type": "time", "pattern": p }),
Format::Special(n) => {
let t = ["zip", "zip4", "phone", "ssn"][(*n).min(3) as usize];
json!({ "type": t })
}
Format::Mask(m) => json!({ "type": "mask", "mask": m }),
}
}
fn kind_name(k: FormFieldKind) -> &'static str {
match k {
FormFieldKind::Text => "text",
FormFieldKind::CheckBox => "checkbox",
FormFieldKind::Radio => "radio",
FormFieldKind::PushButton => "button",
FormFieldKind::Combo => "combo",
FormFieldKind::List => "list",
FormFieldKind::Signature => "signature",
}
}
/// A JSON number as the radio option it names: its integer spelling when it is a whole number
/// (`1`, `1.0` and `1e0` are all `1`; `-0` is `0`), otherwise JSON's own spelling (`1.5`), which
/// names only an option exported as exactly that text. A whole number at or past 2^53, where a
/// float no longer holds every integer, keeps JSON's spelling too (`1e300`), so it names no option
/// by accident.
fn number_option(n: &serde_json::Number) -> String {
// Integers as parsed (u64, i64) print exactly.
if n.is_u64() || n.is_i64() {
return n.to_string();
}
const EXACT: f64 = 9_007_199_254_740_992.0; // 2^53
match n.as_f64() {
// Finite, whole and below 2^53 in magnitude, so the cast is exact and in range.
Some(x) if x.is_finite() && x.fract() == 0.0 && x.abs() < EXACT => (x as i64).to_string(),
_ => n.to_string(),
}
}
/// A JSON value → the value for this field (booleans for check boxes, arrays for lists…).
fn value_for(f: &FormField, v: &Value) -> Result<FieldValue> {
let wrong = |what: &str| ToolError::InvalidArgs(format!("{}: {what}", f.name));
Ok(match (f.kind, v) {
(FormFieldKind::CheckBox, Value::Bool(b)) => FieldValue::Check(*b),
// {"widget": n} turns on button n (1-based, as form_fields numbers them) and never turns it
// off: radio buttons that share an on state stay exclusive (ISO 32000-2 Table 229).
(FormFieldKind::CheckBox | FormFieldKind::Radio, Value::Object(o)) => {
let n = o.get("widget").and_then(Value::as_u64).filter(|&n| n >= 1).ok_or_else(|| wrong("use {\"widget\": n} with n from 1"))?;
let i = usize::try_from(n - 1).map_err(|_| wrong("there is no such widget"))?;
if f.widgets.get(i).and_then(|w| w.on_state.as_ref()).is_none() {
return Err(wrong(&format!("there is no button {n} with an on state (the field has {})", f.widgets.len())));
}
FieldValue::Widget(i)
}
(FormFieldKind::Radio, Value::Null) => FieldValue::Radio(None),
// An option as `form_fields` lists it (an export value) names its button's on state. The
// export values are matched first and the on-state names after them
// (`Field::state_for_export`): with `/Opt [(1) (2)]` and states `/0` `/1`, `1` is the first
// button, not the second one's state. A value matching neither is refused when written,
// with the options listed.
(FormFieldKind::Radio, Value::String(s)) => FieldValue::Radio(Some(f.state_for_export(s).unwrap_or(s).to_owned())),
// A number is the same option written without quotes (`1` for the export value `1`).
// Below 2^53 it is compared as a number with the export values first, so `1.0` names the button
// exported as `1.0` (`/Opt [(1.0) (2.0)]`, states `/0 /1`) and not the one whose state is
// `/1`; then as text, as a string would be.
(FormFieldKind::Radio, Value::Number(n)) => {
if let Some(state) = n.as_f64().and_then(|x| f.state_for_number(x)) {
FieldValue::Radio(Some(state.to_owned()))
} else {
let s = number_option(n);
FieldValue::Radio(Some(f.state_for_export(&s).map_or_else(|| s.clone(), str::to_owned)))
}
}
(FormFieldKind::Combo | FormFieldKind::List, Value::Array(a)) => FieldValue::Choice(
a.iter().map(|x| x.as_str().map(str::to_owned).ok_or_else(|| wrong("list values must be strings"))).collect::<Result<_>>()?,
),
(FormFieldKind::Combo | FormFieldKind::List, Value::String(s)) => FieldValue::Choice(if s.is_empty() { Vec::new() } else { vec![s.clone()] }),
(_, Value::String(s)) => FieldValue::Text(s.clone()),
(_, Value::Number(n)) => FieldValue::Text(n.to_string()),
(_, Value::Bool(b)) => FieldValue::Text(b.to_string()),
_ => return Err(wrong("use a string (text, radio option, choice), true/false (check box) or an array (multi-select list)")),
})
}
impl Automation {
/// Import an image using an optional exact field ID, preserving the legacy name-only tool.
pub(crate) fn form_set_image(&mut self, a: &Args) -> Result<Value> {
let name = a.str("field")?.to_owned();
let object = a
.0
.get("field_id")
.map(|value| -> Result<pdfcraft_cos::ObjRef> {
let key =
value.as_u64().filter(|key| *key < 1u64 << 48).ok_or_else(|| bad("field_id must be an integer between 0 and 281474976710655"))?;
Ok(pdfcraft_cos::ObjRef::new((key >> 16) as u32, key as u16))
})
.transpose()?;
let path = self.resolve(a.str("path")?, false)?;
let image = std::sync::Arc::new(crate::paths::read_input(&path)?);
let edit = match object {
Some(object) => Edit::SetFieldImageTarget { name, object, image },
None => Edit::SetFieldImage { name, image },
};
self.apply(a, edit)
}
pub(crate) fn form_fields(&self, a: &Args) -> Result<Value> {
let doc = self.doc(a)?;
let out: Vec<Value> = doc
.form
.iter()
.map(|f| {
let w = f.widgets.first();
let rect = w.and_then(|w| {
let p = doc.info.pages.get(w.page?)?;
let (a, b) = (p.user_to_view(w.rect[0] as f32, w.rect[1] as f32), p.user_to_view(w.rect[2] as f32, w.rect[3] as f32));
Some([a[0].min(b[0]), a[1].min(b[1]), a[0].max(b[0]), a[1].max(b[1])].map(|v| (v * 100.0).round() / 100.0))
});
let mut v = json!({
"name": f.name,
"field_id": pdfcraft_engine::js::field_object_id(f.obj),
"type": kind_name(f.kind),
"value": match f.kind {
FormFieldKind::CheckBox => json!(!f.value.is_empty()),
FormFieldKind::List if f.has(field_flags::MULTI_SELECT) => json!(f.value),
FormFieldKind::Radio => json!(f.value.first().map(|v| f.export_for_state(v))),
_ => json!(f.value.first()),
},
"page": w.and_then(|w| w.page).map(|p| p + 1),
"rect": rect,
"rotation": w.map(|w| w.rotation).unwrap_or(0),
"read_only": f.read_only(),
"required": f.has(field_flags::REQUIRED),
});
let mut more = serde_json::Map::new();
let o = &mut more;
if let Some(t) = &f.tooltip {
o.insert("tooltip".into(), json!(t));
}
if let Some(s) = doc.field_check_style(&f.name) {
o.insert("check_style".into(), json!(s.label().to_lowercase()));
}
if f.actions.format != Format::None {
o.insert("format".into(), format_json(&f.actions.format));
o.insert("display".into(), json!(f.value.first().map(|v| pdfcraft_engine::form_scripts::format_value(&f.actions.format, v))));
}
if let Validate::Range { min, max } = &f.actions.validate {
o.insert("validate".into(), json!({ "min": min, "max": max }));
}
if let Some(c) = calculate_json(f) {
o.insert("calculate".into(), c);
}
if !f.actions.unsupported.is_empty() {
o.insert("unsupported_scripts".into(), json!(f.actions.unsupported));
}
if matches!(f.kind, FormFieldKind::CheckBox | FormFieldKind::Radio) {
o.insert("export_values".into(), json!((0..f.widgets.len()).map(|i| f.export_of(i)).collect::<Vec<_>>()));
}
match f.kind {
FormFieldKind::PushButton => {
o.insert("caption".into(), json!(w.and_then(|w| w.caption.as_deref()).unwrap_or_default()));
o.insert(
"captions".into(),
json!(
f.widgets
.iter()
.enumerate()
.map(|(i, w)| { json!({"widget": i.saturating_add(1), "caption": w.caption.as_deref().unwrap_or_default()}) })
.collect::<Vec<_>>()
),
);
}
FormFieldKind::Radio => {
o.insert("options".into(), json!((0..f.widgets.len()).filter_map(|i| f.export_of(i)).collect::<Vec<_>>()));
// Buttons sharing an on state have the same value: which is on (1-based).
o.insert(
"on_widgets".into(),
json!((0..f.widgets.len()).filter(|&i| f.widget_on(i)).map(|i| i.saturating_add(1)).collect::<Vec<_>>()),
);
}
// A check box whose kids have different on states (ISO 32000-2 §12.7.5.2.3):
// which one is ticked, and the names form_fill takes to tick each. One option
// per distinct on state: kids sharing a state tick together (EXAMPLE 2), so
// their `/Opt` exports are not separate choices. Exports name the options
// unless two states share one (Table 230 allows it) or an export would tick
// another state (form_fill reads a name as an on state first); then the
// states do.
FormFieldKind::CheckBox => {
let mut seen = std::collections::HashSet::new();
let states: Vec<&str> = f.widgets.iter().filter_map(|w| w.on_state.as_deref()).filter(|s| seen.insert(*s)).collect();
if states.len() > 1 {
let mut exports = std::collections::HashSet::new();
let by_export = states.iter().all(|s| {
let e = f.export_for_state(s);
exports.insert(e) && f.state_for(e) == Some(*s)
});
let name = |s: &str| if by_export { f.export_for_state(s).to_owned() } else { s.to_owned() };
// The ticked kid is the one whose `/AS` is its on state: §12.7.5.2.3
// makes `/AS` decide where it and `/V` differ.
let on = (0..f.widgets.len()).find(|&i| f.widget_on(i)).and_then(|i| f.widgets.get(i)?.on_state.as_deref());
o.insert("options".into(), json!(states.iter().map(|s| name(s)).collect::<Vec<_>>()));
o.insert("checked".into(), json!(on.map(name)));
}
}
FormFieldKind::Combo | FormFieldKind::List => {
o.insert("options".into(), f.options.iter().map(|(e, d)| json!({ "value": e, "label": d })).collect());
// The selected options as 1-based positions in `options`: two options can share
// an export value, and `value` alone does not tell them apart.
o.insert("selected".into(), json!(f.selected.iter().map(|i| i.saturating_add(1)).collect::<Vec<_>>()));
o.insert("multi_select".into(), json!(f.has(field_flags::MULTI_SELECT)));
o.insert("editable".into(), json!(f.has(field_flags::EDIT)));
}
FormFieldKind::Text => {
o.insert("multiline".into(), json!(f.has(field_flags::MULTILINE)));
if let Some(m) = f.max_len {
o.insert("max_length".into(), json!(m));
}
}
_ => {}
}
if let Value::Object(m) = &mut v {
m.extend(more);
}
v
})
.collect();
Ok(json!({ "count": out.len(), "fields": out }))
}
pub(crate) fn form_fill(&mut self, a: &Args) -> Result<Value> {
let doc = self.doc(a)?;
let values = a
.get("values")
.and_then(Value::as_object)
.ok_or_else(|| ToolError::InvalidArgs("values must be an object of field name → value".into()))?;
if values.is_empty() {
return Err(ToolError::InvalidArgs("values is empty".into()));
}
let mut edits = Vec::new();
for (name, v) in values {
let f = doc.form.iter().find(|f| &f.name == name).ok_or_else(|| failed(format!("there is no field named {name:?} (see form_fields)")))?;
edits.push(Edit::SetFieldValue { name: name.clone(), value: value_for(f, v)? });
}
let edit = if edits.len() == 1 { edits.remove(0) } else { Edit::Batch { label: "Fill in form".into(), edits } };
let mut out = self.apply(a, edit)?;
out["filled"] = json!(values.len());
// A value the box cuts off at a fixed font size is saved whole but shows only in part:
// said, not silent (#4018).
let doc = self.doc(a)?;
let cut: Vec<Value> = values
.keys()
.filter_map(|name| doc.field_overflow(name).map(|(page, o)| (name, page, o)))
.map(|(name, page, o)| {
let page = page.map(|p| format!(" (page {})", p.saturating_add(1))).unwrap_or_default();
json!(format!("form field {name:?}{page}: {o}, so the box shows only part of it (the whole value is saved)"))
})
.collect();
if !cut.is_empty() {
match out.get_mut("warnings").and_then(Value::as_array_mut) {
Some(w) => w.extend(cut),
None => out["warnings"] = Value::Array(cut),
}
}
Ok(out)
}
pub(crate) fn form_reset(&mut self, a: &Args) -> Result<Value> {
let names = a.get("fields").map(|_| a.strs("fields").map(|v| v.into_iter().map(str::to_owned).collect::<Vec<_>>())).transpose()?;
self.apply(a, Edit::ResetForm { names })
}
pub(crate) fn form_add_field(&mut self, a: &Args) -> Result<Value> {
let page = self.page(a)?;
let doc = self.doc(a)?;
let options = || -> Result<Vec<String>> {
Ok(a.get("options").map(|_| a.strs("options")).transpose()?.unwrap_or_default().into_iter().map(str::to_owned).collect())
};
let kind = match a.str("type")? {
"text" => NewField::Text { multiline: a.opt_bool("multiline")?.unwrap_or(false) },
"date" => NewField::Date,
"checkbox" => NewField::CheckBox,
"radio" => {
NewField::Radio { group: a.opt_str("group")?.map(str::to_owned), export: a.opt_str("export")?.unwrap_or("Choice1").to_owned() }
}
"combo" => NewField::Combo { options: options()?, editable: a.opt_bool("editable")?.unwrap_or(false) },
"list" => NewField::List { options: options()?, multi: a.opt_bool("multi_select")?.unwrap_or(false) },
"button" => NewField::Button { caption: a.opt_str("caption")?.unwrap_or("").to_owned() },
"image" => NewField::Image,
"signature" => NewField::Signature,
t => return Err(ToolError::InvalidArgs(format!("unknown field type {t:?}"))),
};
let r: Vec<f64> = a.get("rect").and_then(Value::as_array).map(|x| x.iter().filter_map(Value::as_f64).collect()).unwrap_or_default();
let r = <[f64; 4]>::try_from(r).map_err(|_| ToolError::InvalidArgs("rect must be 4 numbers".into()))?;
// Top-left-origin points on the displayed page → user space.
let p = &doc.info.pages[page];
let (u0, u1) = (p.view_to_user(r[0] as f32, r[1] as f32), p.view_to_user(r[2] as f32, r[3] as f32));
let rect = [u0[0].min(u1[0]) as f64, u0[1].min(u1[1]) as f64, u0[0].max(u1[0]) as f64, u0[1].max(u1[1]) as f64];
if rect[2] - rect[0] < 1.0 || rect[3] - rect[1] < 1.0 {
return Err(ToolError::InvalidArgs("rect is empty".into()));
}
let before: Vec<String> = doc.form.iter().map(|f| f.name.clone()).collect();
let name = a.opt_str("name")?.map(str::to_owned);
let mut out = self.apply(a, Edit::AddField { page, rect, kind, name })?;
let doc = self.doc(a)?;
let added = doc
.form
.iter()
.map(|f| &f.name)
.find(|n| !before.contains(n))
.or_else(|| a.opt_str("group").ok().flatten().and_then(|g| doc.form.iter().map(|f| &f.name).find(|n| *n == g)));
out["field"] = json!(added);
Ok(out)
}
/// A table of text fields: `rect` (points from the top-left of the displayed page) divided
/// into rows and columns, one field per cell, as one undo step.
pub(crate) fn form_add_field_table(&mut self, a: &Args) -> Result<Value> {
use pdfcraft_engine::field_table::{self, FieldTable, MAX_COLUMNS, TableColumn, TableError};
let page = self.page(a)?;
let rect = a
.get("rect")
.and_then(Value::as_array)
.and_then(|r| r.iter().map(Value::as_f64).collect::<Option<Vec<f64>>>())
.and_then(|r| <[f64; 4]>::try_from(r).ok())
.ok_or_else(|| bad("rect must be 4 numbers"))?;
let names = a.get("names").map(|_| a.strs("names")).transpose()?;
let widths = match a.get("widths") {
None => None,
Some(v) => Some(
v.as_array()
.and_then(|w| w.iter().map(Value::as_f64).collect::<Option<Vec<f64>>>())
.ok_or_else(|| bad("widths must be an array of numbers"))?,
),
};
// A negative count is no count: the table refuses it with its own words.
let counts = [a.opt_int("columns")?.map(|n| usize::try_from(n).unwrap_or(0)), names.as_ref().map(Vec::len), widths.as_ref().map(Vec::len)];
let mut given = counts.into_iter().flatten();
let columns = given.next().unwrap_or(1);
if given.any(|n| n != columns) {
return Err(bad("columns, names and widths must agree on the number of columns"));
}
// Before anything is sized by it.
if !(1..=MAX_COLUMNS).contains(&columns) {
return Err(bad(TableError::Columns.to_string()));
}
let (edit, fields) = {
let doc = self.doc(a)?;
let base = field_table::free_base(doc.form.iter().map(|f| f.name.as_str()));
let mut table = FieldTable::new(rect, &base);
table.rows = usize::try_from(a.int("rows")?).unwrap_or(0);
table.columns = (0..columns)
.map(|c| TableColumn {
pattern: names.as_ref().and_then(|n| n.get(c)).map_or_else(|| field_table::column_pattern(&base, c + 1), |p| (*p).to_owned()),
width: widths.as_ref().and_then(|w| w.get(c)).copied().unwrap_or(1.0),
})
.collect();
table.row_gap = a.opt_num("row_gap")?.unwrap_or(0.0);
table.column_gap = a.opt_num("column_gap")?.unwrap_or(0.0);
table.first_row = a.opt_int("first_row")?.map_or(1, |n| usize::try_from(n).unwrap_or(usize::MAX));
let info = doc.info.pages.get(page).ok_or_else(|| bad(format!("page {} does not exist", page + 1)))?;
let edit = field_table::edit(&table, page, info, &doc.form).map_err(|e| bad(e.to_string()))?;
// The names the batch adds, in the order it adds them.
let fields: Vec<String> = match &edit {
Edit::Batch { edits, .. } => {
edits.iter().filter_map(|e| if let Edit::AddField { name, .. } = e { name.clone() } else { None }).collect()
}
_ => Vec::new(),
};
(edit, fields)
};
let mut out = self.apply(a, edit)?;
out["fields"] = json!(fields);
Ok(out)
}
pub(crate) fn form_set_props(&mut self, a: &Args) -> Result<Value> {
if a.get("widget").is_some() && a.get("caption").is_none() && a.get("export_value").is_none() {
return Err(bad("widget requires caption or export_value"));
}
let single = a.opt_str("field")?;
let bulk = a.get("fields").is_some();
if single.is_some() == bulk {
return Err(bad("give either field or fields, not both"));
}
let names: Vec<String> = if bulk {
let count = a.get("fields").and_then(Value::as_array).ok_or_else(|| bad("fields must be an array of field names"))?.len();
if count == 0 || count > 1_000 {
return Err(bad("fields must contain between 1 and 1000 names"));
}
let names = a.strs("fields")?;
let mut seen = std::collections::HashSet::new();
for name in &names {
if !seen.insert(*name) {
return Err(bad(format!("field {name:?} is listed more than once")));
}
}
for key in ["name", "rect", "rotation", "caption", "export_value", "widget"] {
if a.get(key).is_some() {
return Err(bad(format!("{key} applies to one field; use field instead of fields")));
}
}
names.into_iter().map(str::to_owned).collect()
} else {
single.into_iter().map(str::to_owned).collect()
};
let mut edits = Vec::with_capacity(names.len());
let mut new_name = None;
for name in &names {
// Merge appearance changes separately for each field: changing just the border
// must not copy the first field's fill, font or text colour onto the others.
let props = self.field_props_arg(a, name)?;
if props == FieldProps::default() {
return Err(bad("nothing to change"));
}
new_name = Some(props.name.clone().unwrap_or_else(|| name.clone()));
edits.push(Edit::SetFieldProps { name: name.clone(), props: Box::new(props) });
}
let edit = if bulk {
Edit::Batch { label: "Change field properties".into(), edits }
} else {
edits.pop().ok_or_else(|| bad("give a field name"))?
};
// A rename also renames the field in the formulas that read it.
let before = if a.get("name").is_some() { Some(self.doc(a)?.form.clone()) } else { None };
let mut out = self.apply(a, edit)?;
if bulk {
out["fields"] = json!(names);
} else {
out["field"] = json!(new_name);
}
if let Some(before) = before {
let after = &self.doc(a)?.form;
// The renamed field itself, found by object: the name stored can differ from the
// argument (trimmed, kept under its parent's prefix).
let own =
names.first().and_then(|n| before.iter().find(|f| &f.name == n)).and_then(|f| after.iter().find(|g| g.obj == f.obj)).map(|g| &g.name);
let mut updated = pdfcraft_engine::form_calculations_changed(&before, after);
updated.retain(|f| Some(f) != own);
out["formulas_updated"] = json!(updated);
}
Ok(out)
}
fn field_props_arg(&self, a: &Args, name: &str) -> Result<FieldProps> {
let doc = self.doc(a)?;
let f = doc.form.iter().find(|f| f.name == name).ok_or_else(|| failed(format!("there is no field named {name:?} (see form_fields)")))?;
// Position: top-left-origin points on the displayed page → user space.
let rect = match a.get("rect") {
None => None,
Some(v) => {
let r: Vec<f64> = v.as_array().map(|x| x.iter().filter_map(Value::as_f64).collect()).unwrap_or_default();
let r = <[f64; 4]>::try_from(r).map_err(|_| ToolError::InvalidArgs("rect must be 4 numbers".into()))?;
let page = f.widgets.first().and_then(|w| w.page).ok_or_else(|| failed(format!("{name} is not on a page")))?;
let p = doc.info.pages.get(page).ok_or_else(|| failed(format!("{name} is on page {}, which could not be read", page + 1)))?;
let (u0, u1) = (p.view_to_user(r[0] as f32, r[1] as f32), p.view_to_user(r[2] as f32, r[3] as f32));
Some((0, [u0[0].min(u1[0]) as f64, u0[1].min(u1[1]) as f64, u0[0].max(u1[0]) as f64, u0[1].max(u1[1]) as f64]))
}
};
Ok(FieldProps {
rect,
export_value: a
.opt_str("export_value")?
.map(|value| -> Result<_> {
let index = a.opt_int("widget")?.unwrap_or(1);
let index =
usize::try_from(index).ok().and_then(|n| n.checked_sub(1)).ok_or_else(|| bad("widget must be a positive 1-based index"))?;
Ok((index, value.to_owned()))
})
.transpose()?,
name: a.opt_str("name")?.map(str::to_owned),
tooltip: a.opt_str("tooltip")?.map(str::to_owned),
caption: a
.opt_str("caption")?
.map(|caption| -> Result<_> {
let wi = a.opt_int("widget")?.unwrap_or(1);
let wi =
usize::try_from(wi).ok().and_then(|wi| wi.checked_sub(1)).ok_or_else(|| bad("widget must be a positive 1-based index"))?;
Ok((wi, caption.to_owned()))
})
.transpose()?,
caption_all: None,
read_only: a.opt_bool("read_only")?,
required: a.opt_bool("required")?,
multiline: a.opt_bool("multiline")?,
max_len: match a.opt_int("max_length")? {
None => None,
Some(0) => Some(None),
Some(n) if n > 0 => Some(Some(n as usize)),
Some(_) => return Err(ToolError::InvalidArgs("max_length must be 0 (no limit) or more".into())),
},
options: a.get("options").map(|_| a.strs("options")).transpose()?.map(|v| v.into_iter().map(str::to_owned).collect()),
font_size: a.opt_num("font_size")?,
look: None,
appearance: a.get("appearance").map(look_arg).transpose()?.filter(|p| *p != pdfcraft_engine::FieldLookPatch::default()),
format: a.get("format").map(format_arg).transpose()?,
validate: a.get("validate").map(validate_arg).transpose()?,
calculate: a.get("calculate").map(calculate_arg).transpose()?,
quadding: match a.opt_str("align")? {
None => None,
Some("left") => Some(0),
Some("center" | "centre") => Some(1),
Some("right") => Some(2),
Some(o) => return Err(bad(format!("unknown alignment {o:?} (left, center, right)"))),
},
default_value: a.opt_str("default")?.map(|d| (!d.is_empty()).then(|| d.to_owned())),
locked: a.opt_bool("locked")?,
flags: match a.get("flags") {
None => Vec::new(),
Some(v) => {
use pdfcraft_engine::field_flags as ff;
let obj = v.as_object().ok_or_else(|| bad("flags must be an object of booleans"))?;
let mut out = Vec::new();
for (k, on) in obj {
let on = on.as_bool().ok_or_else(|| bad(format!("flags.{k} must be true or false")))?;
// Acrobat's wording: "Scroll long text" and "Check spelling" are the
// inverse of their bits.
let (bit, inverted) = match k.as_str() {
"scroll" => (ff::DO_NOT_SCROLL, true),
"rich_text" => (ff::RICH_TEXT, false),
"password" => (ff::PASSWORD, false),
"file_select" => (ff::FILE_SELECT, false),
"spell_check" => (ff::DO_NOT_SPELL_CHECK, true),
"comb" => (ff::COMB, false),
"sort" => (ff::SORT, false),
"editable" => (ff::EDIT, false),
"multi_select" => (ff::MULTI_SELECT, false),
"commit_immediately" => (ff::COMMIT_ON_SEL_CHANGE, false),
"radios_in_unison" => (ff::RADIOS_IN_UNISON, false),
other => return Err(bad(format!("unknown flag {other:?}"))),
};
out.push((bit, on != inverted));
}
out
}
},
rotation: match a.opt_int("rotation")? {
None => None,
Some(r @ (0 | 90 | 180 | 270)) => Some((0, r)),
Some(r) => return Err(ToolError::InvalidArgs(format!("rotation must be 0, 90, 180 or 270, not {r}"))),
},
actions: None,
check_style: match a.opt_str("check_style")? {
None => None,
Some(s) => Some(
pdfcraft_engine::CheckStyle::ALL
.into_iter()
.find(|c| c.label().eq_ignore_ascii_case(s))
.ok_or_else(|| bad(format!("unknown check style {s:?} (check, circle, cross, diamond, square, star)")))?,
),
},
})
}
pub(crate) fn form_tab_order(&mut self, a: &Args) -> Result<Value> {
let n = self.doc(a)?.info.pages.len();
let pages = match a.opt_ints("pages")? {
Some(_) => self.pages(a, "pages")?,
None => (0..n).collect(),
};
// Order tabs manually: move one field earlier or later on its page.
if let Some(field) = a.opt_str("field")? {
let earlier = match a.opt_str("move")?.unwrap_or("earlier") {
"earlier" => true,
"later" => false,
m => return Err(bad(format!("move must be earlier or later, not {m:?}"))),
};
let mut out = self.apply(a, Edit::MoveInTabOrder { name: field.to_owned(), earlier })?;
out["tab_order"] = self.tab_sequence(a)?;
out["page_tabs"] = self.page_tabs(a)?;
return Ok(out);
}
let order = match a.opt_str("order")? {
None => None,
Some("row" | "rows") => Some(pdfcraft_engine::TabOrder::Row),
Some("column" | "columns") => Some(pdfcraft_engine::TabOrder::Column),
Some("structure") => Some(pdfcraft_engine::TabOrder::Structure),
Some("annotations" | "unspecified") => Some(pdfcraft_engine::TabOrder::Annotations),
Some(o) => return Err(bad(format!("unknown order {o:?} (row, column, structure, annotations)"))),
};
let mut out = match order {
Some(order) => self.apply(a, Edit::SetTabOrder { pages, order })?,
None => json!({}),
};
out["tab_order"] = self.tab_sequence(a)?;
out["page_tabs"] = self.page_tabs(a)?;
Ok(out)
}
pub(crate) fn form_calculation_order(&mut self, a: &Args) -> Result<Value> {
let current = self.doc(a)?.calculation_order();
let fix = a.opt_bool("fix")?.unwrap_or(false);
let field = a.opt_str("field")?;
let order = a.get("order").map(|_| a.strs("order")).transpose()?;
if usize::from(fix) + usize::from(field.is_some()) + usize::from(order.is_some()) > 1 {
return Err(bad("give one change at a time: order, field with move, or fix"));
}
let names: Option<Vec<String>> = if let Some(order) = order {
Some(order.into_iter().map(str::to_owned).collect())
} else if let Some(field) = field {
let earlier = match a.opt_str("move")?.unwrap_or("earlier") {
"earlier" => true,
"later" => false,
m => return Err(bad(format!("move must be earlier or later, not {m:?}"))),
};
let mut names: Vec<String> = current.fields.iter().map(|f| f.name.clone()).collect();
let i = names.iter().position(|n| n == field).ok_or_else(|| bad(format!("{field:?} is not a calculated field")))?;
// Already first or last: nothing moves.
let j = if earlier { i.checked_sub(1) } else { Some(i + 1).filter(|j| *j < names.len()) };
j.map(|j| {
names.swap(i, j);
names
})
} else {
(fix && current.fixable()).then(|| current.fixed())
};
let mut out = match names {
Some(names) => self.apply(a, Edit::SetCalculationOrder { names })?,
None => json!({}),
};
let order = self.doc(a)?.calculation_order();
let issue = |i: &CalculationIssue| match i {
CalculationIssue::MissingField { field } => json!({ "issue": "missing_field", "field": field }),
CalculationIssue::RunsTooEarly { field } => json!({ "issue": "runs_too_early", "field": field }),
CalculationIssue::Cycle { fields } => json!({ "issue": "cycle", "fields": fields }),
CalculationIssue::NotInOrder => json!({ "issue": "not_in_order" }),
CalculationIssue::BuiltNames => json!({ "issue": "built_names" }),
};
// By field object, not name: two calculated fields can share a name in a malformed form.
let doc = self.doc(a)?;
let by_obj: std::collections::HashMap<_, &FormField> = doc.form.iter().map(|f| (f.obj, f)).collect();
out["calculation_order"] = json!(order.fields.iter().map(|f| f.name.as_str()).collect::<Vec<_>>());
out["fields"] = order
.fields
.iter()
.map(|f| {
json!({
"name": f.name,
"formula": by_obj.get(&f.obj).and_then(|x| calculate_json(x)),
"reads": f.reads,
"listed": f.listed,
"issues": f.issues.iter().map(issue).collect::<Vec<_>>(),
})
})
.collect();
out["extra"] = json!(order.extra);
out["fixable"] = json!(order.fixable());
Ok(out)
}
fn tab_sequence(&self, a: &Args) -> Result<Value> {
let doc = self.doc(a)?;
let mut seq: Vec<(usize, &str)> =
doc.form.iter().flat_map(|f| f.widgets.iter().filter(|w| w.page.is_some()).map(move |w| (w.tab, f.name.as_str()))).collect();
seq.sort();
seq.dedup_by(|x, y| x.1 == y.1);
Ok(json!(seq.iter().map(|x| x.1).collect::<Vec<_>>()))
}
/// Each page's tab order (Page properties ▸ Tab order), in page order.
fn page_tabs(&self, a: &Args) -> Result<Value> {
let names: Vec<&str> = self
.doc(a)?
.tab_orders()
.into_iter()
.map(|o| match o {
pdfcraft_engine::TabOrder::Row => "row",
pdfcraft_engine::TabOrder::Column => "column",
pdfcraft_engine::TabOrder::Structure => "structure",
pdfcraft_engine::TabOrder::Annotations => "annotations",
})
.collect();
Ok(json!(names))
}
pub(crate) fn form_delete_field(&mut self, a: &Args) -> Result<Value> {
let name = a.str("field")?.to_owned();
if !self.doc(a)?.form.iter().any(|f| f.name == name) {
return Err(failed(format!("there is no field named {name:?} (see form_fields)")));
}
let Some(widget) = a.opt_int("widget")? else { return self.apply(a, Edit::DeleteField { name }) };
let count = self.doc(a)?.form.iter().find(|f| f.name == name).map_or(0, |f| f.widgets.len());
let widget = usize::try_from(widget)
.ok()
.and_then(|w| w.checked_sub(1))
.filter(|w| *w < count)
.ok_or_else(|| bad(format!("widget must be 1 to {count}: {name} has {count} widgets")))?;
self.apply(a, Edit::DeleteWidget { name, widget })
}
pub(crate) fn doc_export_data(&mut self, a: &Args) -> Result<Value> {
let id = self.doc(a)?.id;
let path = self.resolve(a.str("path")?, true)?;
let ext = path.extension().map(|e| e.to_string_lossy().into_owned()).unwrap_or_default();
let format =
pdfcraft_engine::DataFormat::from_extension(&ext).ok_or_else(|| bad("the path must end in .xfdf, .fdf, .xml, .xdp, .csv or .txt"))?;
let (comments, fields) = match a.opt_str("what")?.unwrap_or("all") {
"all" => (true, true),
"comments" => (true, false),
"fields" => (false, true),
w => return Err(bad(format!("unknown what {w:?} (all, comments, fields)"))),
};
if comments && !matches!(format, pdfcraft_engine::DataFormat::Xfdf | pdfcraft_engine::DataFormat::Fdf) {
return Err(bad("comments travel as .xfdf or .fdf; .xml, .csv and .txt hold form data (use what: fields)"));
}
let bytes = self.session.export_data(id, format, comments, fields).map_err(failed)?;
crate::write_atomic(&path, &bytes)?;
Ok(json!({ "path": path.to_string_lossy(), "bytes": bytes.len() }))
}
pub(crate) fn doc_import_data(&mut self, a: &Args) -> Result<Value> {
let path = self.resolve(a.str("path")?, false)?;
let bytes = crate::read_input(&path)?;
let name = path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default();
let before = (self.doc(a)?.info.annotations.len(), self.doc(a)?.form.iter().filter(|f| !f.value.is_empty()).count());
let mut out = self.apply(a, Edit::ImportData { name, bytes: std::sync::Arc::new(bytes) })?;
let doc = self.doc(a)?;
out["comments"] = json!(doc.info.annotations.len() as i64 - before.0 as i64);
out["filled_fields"] = json!(doc.form.iter().filter(|f| !f.value.is_empty()).count());
Ok(out)
}
}