mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-11-12 05:42:52 +00:00
feat(forms): open, Will Save and Will Print actions, widget pointer/focus scripts, setItems/setAction/toSource (port of #344) (#6969)
* feat(js, forms): field setItems, setAction and a bounded toSource (#269) Ported from #344 by @DWAA1660 onto the per-document script session. - `field.setItems(items)` replaces a combo or list box's items (text, or [text, export] pairs). The value stays when an item still has it, and otherwise becomes the first item's. Written as the field's /Opt (pairs only where the export value differs), /I dropped, field redrawn. - `field.setAction(trigger, script)` installs a JavaScript action for one trigger (MouseUp/Down/Enter/Exit, OnFocus/OnBlur, Keystroke, Validate, Calculate, Format), replacing only that trigger's action. - `Object.prototype.toSource()` writes plain data (strings, numbers, booleans, null, undefined, arrays, objects) that eval reads back. It refuses functions, cycles, more than 64 levels, 10,000 values or 1 MiB with an error the script can catch. Co-authored-by: DWAA1660 <dwatnip123@gmail.com> * feat(engine): run a form's open, Will Save and Will Print actions and its widgets' pointer and focus scripts (#269) Ported from #344 by @DWAA1660 onto the per-document script session: each runs in the document's retained realm, so state the document scripts and earlier events left is kept. - The catalog /OpenAction's JavaScript (and its /Next chain, bounded and cycle-safe; other action types skipped) runs once on opening, after the document scripts, for background opens and reloads too, and again on Revert in a fresh realm. Event Doc/Open. - `Session::run_document_actions` runs /AA /WS (Will Save) or /AA /WP (Will Print). A failing action is reported and never stops the save or print. doc_save and doc_print run them first and report what they said (`will_save`, `will_print`). - Scripts that run unasked (open, Will Save, Will Print, pointer over, focus) may only ask for highlight, zoom, focus or a page; a click's Mouse Down / Mouse Up keep their requests. - `Session::field_trigger` runs a widget's Mouse Enter, Mouse Exit, Mouse Down, Mouse Up, Focus or Blur JavaScript (one undoable edit), reading only that widget's dictionary. `field_focus` now runs an AcroForm widget's On Focus / On Blur as well as XFA enter/exit. - New tool `js_event`; form_focus's description says AcroForm focus scripts run too. Co-authored-by: DWAA1660 <dwatnip123@gmail.com> * feat(ui): Will Save before saving, Will Print before printing, and widget pointer scripts in the app (#269, #247) Ported from #344 by @DWAA1660. - Save (both paths) runs the document's Will Save actions first, and Print its Will Print actions; what they change is saved or printed, and what they say is shown. - The pointer moving onto or off a form widget queues its Mouse Enter / Mouse Exit script, a press its Mouse Down. A click on a check box or radio button queues its Mouse Up after the toggle, so the script reads the value the click stored (#247). The queue runs after the frame's edit. Co-authored-by: DWAA1660 <dwatnip123@gmail.com> * docs(parity): document actions and widget triggers are partly supported (#269) Co-authored-by: DWAA1660 <dwatnip123@gmail.com> * test: check toSource's text (scripts can't eval it) and the trigger event names (#269) Co-authored-by: DWAA1660 <dwatnip123@gmail.com> * test: a check box's Mouse Up reads its new value; keep the fixtures on screen and the realm across an in-place save (#247, #269) Co-authored-by: DWAA1660 <dwatnip123@gmail.com> * fix(engine, forms, js, ui): review fixes for document actions and field triggers - widget trigger actions follow /Next, each action its own edit; errors don't stop the chain - setAction installs on the field the object stands for when names repeat - toSource refuses non-plain objects and a __proto__ key - doc_print checks the destination before Will Print; js_event returns only its own output - combo/list clicks run Mouse Up; a refused click's Mouse Up doesn't run - label crops are re-read when Will Print changed the document Co-authored-by: DWAA1660 <dwatnip123@gmail.com> * forms: run a pointer trigger on the field it hit and refresh the edited field's draft A trigger now carries the field's object, so of fields sharing a name the one under the pointer runs its own script (Session::field_trigger_target); js_event passes the field it resolved. A trigger script changing the field being edited shows in its editor unless the user already typed there, as focus scripts do. Co-authored-by: DWAA1660 <dwatnip123@gmail.com> * forms: carry the field's identity through focus scripts and js_event Focus transitions now track the field object, so moving between fields sharing a name is a move and the field that gained or lost focus runs its own script (Session::field_focus_target). js_event takes an optional field_id from form_fields to pick one of fields sharing a name. Co-authored-by: DWAA1660 <dwatnip123@gmail.com> --------- Co-authored-by: DWAA1660 <dwatnip123@gmail.com>
This commit is contained in:
1 parent
3a117c3c0e
commit
059dbd6e00
32 files changed
+1597
-74
No files matched your search
@@ -21,12 +21,35 @@ fn request_json(r: &pdfcraft_engine::js::Request) -> Value {
|
||||
}
|
||||
}
|
||||
|
||||
/// The optional `field_id` argument: a field's identity from form_fields, selecting one of
|
||||
/// fields sharing a name.
|
||||
fn field_id(a: &Args) -> Result<Option<u64>> {
|
||||
a.0.get("field_id")
|
||||
.map(|v| {
|
||||
v.as_u64()
|
||||
.filter(|id| *id < 1 << 48)
|
||||
.ok_or_else(|| ToolError::InvalidArgs("field_id must be an integer between 0 and 281474976710655".into()))
|
||||
})
|
||||
.transpose()
|
||||
}
|
||||
|
||||
/// A message a script showed, as `js_run` reports it: its text, title (null when it gave none),
|
||||
/// icon and buttons.
|
||||
pub(crate) fn alert_json(m: &pdfcraft_engine::js::Message) -> Value {
|
||||
json!({ "message": m.text, "title": m.title, "icon": m.icon.name(), "buttons": m.buttons.name() })
|
||||
}
|
||||
|
||||
/// What scripts that ran unasked (a document's Will Save or Will Print actions) showed, said
|
||||
/// or asked for, as tools report it.
|
||||
pub(crate) fn js_output_json(out: &pdfcraft_engine::js::JsOutput) -> Value {
|
||||
json!({
|
||||
"alerts": out.alerts.iter().map(alert_json).collect::<Vec<_>>(),
|
||||
"console": out.console,
|
||||
"requests": out.requests.iter().map(request_json).collect::<Vec<_>>(),
|
||||
"errors": out.errors,
|
||||
})
|
||||
}
|
||||
|
||||
impl Automation {
|
||||
/// `js_run`: run `script` in the document, or as button `field`'s script, and report its
|
||||
/// messages, console lines, requests, error, the failing document scripts' errors and result.
|
||||
@@ -44,14 +67,7 @@ impl Automation {
|
||||
.collect::<Result<Vec<_>>>()?,
|
||||
_ => return Err(ToolError::InvalidArgs("answers must be an array of at most 32 strings or null".into())),
|
||||
};
|
||||
let identity =
|
||||
a.0.get("field_id")
|
||||
.map(|v| {
|
||||
v.as_u64()
|
||||
.filter(|id| *id < 1 << 48)
|
||||
.ok_or_else(|| ToolError::InvalidArgs("field_id must be an integer between 0 and 281474976710655".into()))
|
||||
})
|
||||
.transpose()?;
|
||||
let identity = field_id(a)?;
|
||||
let (script, field) = (a.str("script")?, a.opt_str("field")?);
|
||||
let (mut o, edit) =
|
||||
self.session.with_own_js_output(id, |s| s.run_javascript_answers_target(id, script, field, identity, &answers)).map_err(failed)?;
|
||||
@@ -122,6 +138,39 @@ impl Automation {
|
||||
)
|
||||
}
|
||||
|
||||
/// `js_event`: run the JavaScript widget `widget` (1-based) of field `field` has for `event`
|
||||
/// (a trigger id: mouse_enter, mouse_exit, mouse_down, mouse_up, on_focus, on_blur); returns
|
||||
/// whether the form changed and what the script showed or asked for.
|
||||
pub(crate) fn js_event(&mut self, a: &Args) -> Result<Value> {
|
||||
let event = a.str("event")?;
|
||||
let trigger = pdfcraft_engine::FieldTrigger::from_id(event).ok_or_else(|| {
|
||||
ToolError::InvalidArgs(format!("event must be mouse_enter, mouse_exit, mouse_down, mouse_up, on_focus or on_blur, not {event:?}"))
|
||||
})?;
|
||||
let name = a.str("field")?;
|
||||
let identity = field_id(a)?;
|
||||
let doc = self.doc(a)?;
|
||||
let field =
|
||||
doc.form.iter().find(|f| f.name == name && identity.is_none_or(|i| pdfcraft_engine::js::field_object_id(f.obj) == i)).ok_or_else(
|
||||
|| match identity {
|
||||
Some(i) => failed(format!("there is no field named {name:?} with field_id {i} (see form_fields)")),
|
||||
None => failed(format!("there is no field named {name:?} (see form_fields)")),
|
||||
},
|
||||
)?;
|
||||
let widgets = field.widgets.len();
|
||||
let widget = a.opt_int("widget")?.unwrap_or(1);
|
||||
let widget = usize::try_from(widget).ok().and_then(|w| w.checked_sub(1)).filter(|w| *w < widgets).ok_or_else(|| match widgets {
|
||||
0 => ToolError::InvalidArgs(format!("{name:?} has no widget")),
|
||||
n => ToolError::InvalidArgs(format!("widget must be 1 to {n} for {name:?}")),
|
||||
})?;
|
||||
let (id, object) = (doc.id, pdfcraft_engine::js::field_object_id(field.obj));
|
||||
// Only this event's output: what opening or an earlier tool left waiting stays.
|
||||
let (changed, out) =
|
||||
self.session.with_own_js_output(id, |s| s.field_trigger_target(id, name, Some(object), widget, trigger)).map_err(failed)?;
|
||||
let mut result = js_output_json(&out);
|
||||
result["changed"] = json!(changed);
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
/// `form_merge_data`: merge the form data files at `paths` into one CSV at `path`, a row per
|
||||
/// file; returns the path and the row and column counts.
|
||||
pub(crate) fn form_merge_data(&mut self, a: &Args) -> Result<Value> {
|
||||
|
||||
@@ -835,6 +835,7 @@ impl Automation {
|
||||
"accessibility_autotag_form_fields" => self.accessibility_autotag_form_fields(&a)?,
|
||||
"ocr_recognize" => self.ocr_recognize(&a)?,
|
||||
"js_run" => self.js_run(&a)?,
|
||||
"js_event" => self.js_event(&a)?,
|
||||
"js_document_scripts" => self.js_document_scripts(&a)?,
|
||||
"js_set_document_script" => self.js_set_document_script(&a)?,
|
||||
"js_enabled" => self.js_enabled(&a)?,
|
||||
|
||||
@@ -168,11 +168,19 @@ impl Automation {
|
||||
if flatten_fill_sign {
|
||||
self.apply(a, Edit::FlattenFillSign)?;
|
||||
}
|
||||
// The document's Will Save actions run first: what they change is saved (#269).
|
||||
let scripts = self.session.run_document_actions(id, pdfcraft_engine::js::DocumentScriptEvent::WillSave);
|
||||
let bytes = if full { self.session.save_full_bytes(id) } else { self.session.save_bytes(id) }.map_err(failed)?;
|
||||
// A save that adds or removes encryption, or must not keep the old revision (Sanitize,
|
||||
// redaction), is rewritten in full even when an incremental save was asked for.
|
||||
let incremental = !full && bytes.starts_with(&self.doc(a)?.saved_bytes());
|
||||
self.publish_save(id, &target, bytes, incremental, write)
|
||||
let mut out = self.publish_save(id, &target, bytes, incremental, write)?;
|
||||
if !scripts.is_empty()
|
||||
&& let Some(o) = out.as_object_mut()
|
||||
{
|
||||
o.insert("will_save".into(), crate::a11y::form_tools::js_output_json(&scripts));
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Validate the prepared bytes before publishing them or marking the document saved.
|
||||
|
||||
@@ -361,29 +361,50 @@ impl Automation {
|
||||
let settings = print::Settings { pages, paper, orientation, layout, content, raster, crops: Vec::new(), area };
|
||||
let laid_out = print::layout(&sizes, &settings).map_err(|e| bad(e.to_string()))?;
|
||||
let sheets = laid_out.len();
|
||||
// Where it goes is checked before the Will Print actions run, so a call that can't print
|
||||
// doesn't change the document.
|
||||
let destination = match (a.opt_str("path")?, printer) {
|
||||
(Some(p), None) => Destination::File(self.resolve(p, true)?),
|
||||
(None, Some(printer)) => {
|
||||
let actual_printer = resolved_printer.as_deref().unwrap_or(printer);
|
||||
Destination::Printer(spool_job(a, actual_printer, self.doc(a)?.name.clone(), duplex, media.as_deref(), settings.raster, &laid_out)?)
|
||||
}
|
||||
_ => return Err(bad("pass either path (save the print-ready PDF) or printer (a name from printers, or \"default\")")),
|
||||
};
|
||||
// The document's Will Print actions run first: what they change is printed (#269).
|
||||
let scripts = if self.doc(a)?.allows_printing() {
|
||||
self.session.run_document_actions(id, pdfcraft_engine::js::DocumentScriptEvent::WillPrint)
|
||||
} else {
|
||||
Default::default()
|
||||
};
|
||||
let bytes = self.session.print_pdf(id, &settings).map_err(failed)?;
|
||||
let mut out = json!({ "sheets": sheets, "pages": settings.pages.len(), "bytes": bytes.len() });
|
||||
match (a.opt_str("path")?, printer) {
|
||||
(Some(p), None) => {
|
||||
let path = self.resolve(p, true)?;
|
||||
if !scripts.is_empty() {
|
||||
out["will_print"] = crate::a11y::form_tools::js_output_json(&scripts);
|
||||
}
|
||||
match destination {
|
||||
Destination::File(path) => {
|
||||
write_atomic(&path, &bytes)?;
|
||||
out["path"] = json!(path.to_string_lossy());
|
||||
}
|
||||
(None, Some(printer)) => {
|
||||
let actual_printer = resolved_printer.as_deref().unwrap_or(printer);
|
||||
let mut job = spool_job(a, actual_printer, self.doc(a)?.name.clone(), duplex, media.as_deref(), settings.raster, &laid_out)?;
|
||||
Destination::Printer(mut job) => {
|
||||
// A GDI (v3) driver gets transparent sheets flattened (#6155, #5838, #5940); XPS
|
||||
// (v4) drivers print them as they are.
|
||||
let flatten = job.printer.as_deref().is_some_and(spool::gdi_driver);
|
||||
let bytes = pdfcraft_engine::spooler::spooler_copy(bytes, &mut job, flatten);
|
||||
out["job"] = json!(spool::submit(&bytes, &job).map_err(|e| failed(e.to_string()))?);
|
||||
}
|
||||
_ => return Err(bad("pass either path (save the print-ready PDF) or printer (a name from printers, or \"default\")")),
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
}
|
||||
|
||||
/// Where `doc_print` sends the sheets: a print-ready PDF file, or a printer's queue.
|
||||
enum Destination {
|
||||
File(std::path::PathBuf),
|
||||
Printer(spool::Job),
|
||||
}
|
||||
|
||||
/// "WxH" with a unit (in, mm, cm or pt) as a portrait paper size in points; each side 1 to 200
|
||||
/// inches (PDF's own page limit).
|
||||
fn paper_size(spec: &str) -> Option<(f64, f64)> {
|
||||
|
||||
@@ -543,7 +543,7 @@ pub fn tools() -> Vec<ToolDef> {
|
||||
t(
|
||||
"form_focus",
|
||||
"Move the focus into or out of a field",
|
||||
"A field gains (event enter) or loses (event exit) focus, as when someone clicks or tabs into or out of it, and the dynamic XFA form's enter or exit scripts for it run (other forms have none, and nothing happens). widget picks the radio button or copy of the field (1-based, default 1; see form_fields). What the scripts change is one undoable edit, and none when they change nothing. Changing a field's value (form_fill) runs its exit scripts already, so the next exit of that field doesn't run them again. Returns changed, and the scripts' alerts, console output, requests and errors.",
|
||||
"A field gains (event enter) or loses (event exit) focus, as when someone clicks or tabs into or out of it, and the dynamic XFA form's enter or exit scripts for it run; on another form, the widget's own On Focus or On Blur JavaScript runs, if it has one (see also js_event). widget picks the radio button or copy of the field (1-based, default 1; see form_fields). What the scripts change is one undoable edit, and none when they change nothing. Changing a field's value (form_fill) runs its exit scripts already, so the next exit of that field doesn't run them again. Returns changed, and the scripts' alerts, console output, requests and errors.",
|
||||
)
|
||||
.with(schema(
|
||||
json!({
|
||||
@@ -1421,6 +1421,21 @@ pub fn tools() -> Vec<ToolDef> {
|
||||
json!({ "doc": doc(), "script": { "type": "string" }, "field": { "type": "string", "description": "Run as this button's Mouse Up event." }, "field_id": {"type":"integer","minimum":0,"maximum":281474976710655u64,"description":"Optional exact field identity from form_fields; requires matching field name and is scoped to this document."}, "answers": {"type":"array","maxItems":32,"items":{"type":["string","null"],"maxLength":4096},"description":"Ordered popup, response and alert answers; strings are limited to 4096 UTF-8 bytes at runtime; null cancels a popup or response, and selects an alert default (OK, Cancel or No, by button set)."} }),
|
||||
&["doc", "script"],
|
||||
)),
|
||||
t(
|
||||
"js_event",
|
||||
"Run a field's pointer or focus script",
|
||||
"Run the JavaScript action widget `widget` of AcroForm field `field` has for `event`, as when the pointer enters (mouse_enter), leaves (mouse_exit), presses (mouse_down) or releases (mouse_up) it, or it gains (on_focus) or loses (on_blur) the focus, in the document's script realm (variables document scripts and earlier scripts left are kept), with event.name Mouse Enter, Mouse Exit, Mouse Down, Mouse Up, Focus or Blur. Nothing runs when the widget has no JavaScript for that event, JavaScript is off, or the form is a laid-out XFA form (use form_focus and js_run there). What it changes is one undoable edit. Pointer-over and focus scripts run unasked in a viewer, so their requests to print, open Save As or show a panel are dropped (noted on the console); mouse_down and mouse_up keep them. Returns changed, and the script's alerts, console output, requests and errors.",
|
||||
)
|
||||
.with(schema(
|
||||
json!({
|
||||
"doc": doc(),
|
||||
"field": { "type": "string", "description": "Field name (from form_fields)." },
|
||||
"event": { "type": "string", "enum": ["mouse_enter", "mouse_exit", "mouse_down", "mouse_up", "on_focus", "on_blur"] },
|
||||
"field_id": {"type":"integer","minimum":0,"maximum":281474976710655u64,"description":"Optional exact field identity from form_fields: of fields sharing the name, the one to run."},
|
||||
"widget": { "type": "integer", "minimum": 1, "description": "Widget of the field (1-based, default 1): a radio button of the group, or a copy of the field." },
|
||||
}),
|
||||
&["doc", "field", "event"],
|
||||
)),
|
||||
t("js_document_scripts", "Document JavaScripts", "List the document-level JavaScripts (name and source), which define functions field scripts use.")
|
||||
.ro()
|
||||
.cmd("tools.document_js")
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
//! A form's document actions and pointer/focus scripts through the tool table (#269, ported
|
||||
//! from #344): the open action runs on doc_open, Will Save on doc_save, Will Print on
|
||||
//! doc_print, and js_event runs a widget's pointer and focus scripts, all in one realm.
|
||||
|
||||
use pdfcraft_automation::{Automation, Content};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
fn tool(a: &mut Automation, name: &str, args: Value) -> Value {
|
||||
match a.call(name, &args).unwrap().remove(0) {
|
||||
Content::Json(value) => value,
|
||||
other => panic!("expected JSON: {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn pdf_of(objs: &[String]) -> Vec<u8> {
|
||||
let mut out = b"%PDF-1.7\n".to_vec();
|
||||
let mut offsets = Vec::new();
|
||||
for (i, o) in objs.iter().enumerate() {
|
||||
offsets.push(out.len());
|
||||
out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes());
|
||||
}
|
||||
let xref = out.len();
|
||||
out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes());
|
||||
for o in offsets {
|
||||
out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes());
|
||||
}
|
||||
out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes());
|
||||
out
|
||||
}
|
||||
|
||||
/// `log` records what ran; `hot` has Mouse Enter and On Blur scripts. The open action counts
|
||||
/// in a variable the Will Save and Will Print actions and `hot`'s scripts read.
|
||||
fn form() -> Vec<u8> {
|
||||
pdf_of(&[
|
||||
"<< /Type /Catalog /Pages 2 0 R /OpenAction 6 0 R /AA << /WS 7 0 R /WP 8 0 R >> /AcroForm << /Fields [4 0 R 9 0 R] /DA (/Helv 0 Tf 0 g) /DR << /Font << /Helv 5 0 R >> >> >> >>".into(),
|
||||
"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 200 300] >>".into(),
|
||||
"<< /Type /Page /Parent 2 0 R /Annots [4 0 R 9 0 R] >>".into(),
|
||||
"<< /Type /Annot /Subtype /Widget /FT /Tx /T (log) /V () /Rect [20 250 180 270] /P 3 0 R /F 4 >>".into(),
|
||||
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>".into(),
|
||||
"<< /S /JavaScript /JS (var n = 1; getField\\('log'\\).value = 'open' + n;) >>".into(),
|
||||
"<< /S /JavaScript /JS (n++; getField\\('log'\\).value += ' save' + n; console.println\\('saving'\\);) >>".into(),
|
||||
"<< /S /JavaScript /JS (n++; getField\\('log'\\).value += ' print' + n;) >>".into(),
|
||||
"<< /Type /Annot /Subtype /Widget /FT /Tx /T (hot) /V () /Rect [20 200 180 220] /P 3 0 R /F 4 /AA << /E << /S /JavaScript /JS (n++; getField\\('log'\\).value += ' ' + event.name + n;) >> /Bl << /S /JavaScript /JS (this.print\\(\\); getField\\('log'\\).value += ' ' + event.name;) >> >> >>".into(),
|
||||
])
|
||||
}
|
||||
|
||||
fn log(a: &mut Automation, doc: u64) -> Value {
|
||||
let f = tool(a, "form_fields", json!({ "doc": doc }));
|
||||
f["fields"].as_array().unwrap().iter().find(|f| f["name"] == "log").map(|f| f["value"].clone()).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn open_will_save_will_print_and_field_events_share_the_document_realm() {
|
||||
let dir = std::env::temp_dir().join(format!("pdfcraft-document-actions-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
std::fs::write(dir.join("form.pdf"), form()).unwrap();
|
||||
let mut a = Automation::new().with_root(&dir).unwrap().with_clock(|| 1_700_000_000);
|
||||
let doc = tool(&mut a, "doc_open", json!({ "path": "form.pdf" }))["doc"].as_u64().unwrap();
|
||||
assert_eq!(log(&mut a, doc), "open1");
|
||||
|
||||
let entered = tool(&mut a, "js_event", json!({ "doc": doc, "field": "hot", "event": "mouse_enter" }));
|
||||
assert_eq!(entered["changed"], true, "{entered}");
|
||||
assert_eq!(log(&mut a, doc), "open1 Mouse Enter2");
|
||||
// Losing the focus happens unasked: its print request is dropped and noted.
|
||||
let blurred = tool(&mut a, "js_event", json!({ "doc": doc, "field": "hot", "event": "on_blur" }));
|
||||
assert_eq!(blurred["requests"], json!([]), "{blurred}");
|
||||
assert_eq!(log(&mut a, doc), "open1 Mouse Enter2 Blur");
|
||||
let none = tool(&mut a, "js_event", json!({ "doc": doc, "field": "log", "event": "mouse_down" }));
|
||||
assert_eq!(none["changed"], false, "no script: {none}");
|
||||
assert!(a.call("js_event", &json!({ "doc": doc, "field": "hot", "event": "keystroke" })).is_err());
|
||||
assert!(a.call("js_event", &json!({ "doc": doc, "field": "hot", "event": "mouse_up", "widget": 2 })).is_err());
|
||||
|
||||
// Saved in place (incrementally): object numbers stay, so the realm is kept. A full
|
||||
// rewrite starts a fresh realm for the next event.
|
||||
let saved = tool(&mut a, "doc_save", json!({ "doc": doc }));
|
||||
assert_eq!(saved["incremental"], true, "{saved}");
|
||||
std::fs::copy(dir.join("form.pdf"), dir.join("saved.pdf")).unwrap();
|
||||
assert_eq!(saved["will_save"]["console"], json!(["saving"]), "{saved}");
|
||||
assert_eq!(log(&mut a, doc), "open1 Mouse Enter2 Blur save3");
|
||||
|
||||
let printed = tool(&mut a, "doc_print", json!({ "doc": doc, "path": "printed.pdf" }));
|
||||
assert!(printed.get("will_print").is_none(), "nothing to report: {printed}");
|
||||
assert_eq!(log(&mut a, doc), "open1 Mouse Enter2 Blur save3 print4");
|
||||
// A print with nowhere to go fails before Will Print runs.
|
||||
assert!(a.call("doc_print", &json!({ "doc": doc })).is_err());
|
||||
assert_eq!(log(&mut a, doc), "open1 Mouse Enter2 Blur save3 print4");
|
||||
|
||||
// What Will Save changed is in the saved file.
|
||||
tool(&mut a, "js_enabled", json!({ "enabled": false }));
|
||||
let reopened = tool(&mut a, "doc_open", json!({ "path": "saved.pdf" }))["doc"].as_u64().unwrap();
|
||||
assert_eq!(log(&mut a, reopened), "open1 Mouse Enter2 Blur save3");
|
||||
}
|
||||
|
||||
/// Of two fields sharing a name, `field_id` (from form_fields) picks the one js_event runs; by
|
||||
/// name alone it is the first.
|
||||
#[test]
|
||||
fn js_event_runs_the_field_its_field_id_names() {
|
||||
let enter = |text: &str| format!("/AA << /E << /S /JavaScript /JS (getField\\('log'\\).value += '{text}';) >> >>");
|
||||
let pdf = pdf_of(&[
|
||||
"<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [4 0 R 6 0 R 7 0 R] /DA (/Helv 0 Tf 0 g) /DR << /Font << /Helv 5 0 R >> >> >> >>".into(),
|
||||
"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 200 300] >>".into(),
|
||||
"<< /Type /Page /Parent 2 0 R /Annots [4 0 R 6 0 R 7 0 R] >>".into(),
|
||||
"<< /Type /Annot /Subtype /Widget /FT /Tx /T (log) /V () /Rect [20 250 180 270] /P 3 0 R /F 4 >>".into(),
|
||||
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>".into(),
|
||||
format!("<< /Type /Annot /Subtype /Widget /FT /Tx /T (dup) /V () /Rect [20 200 180 220] /P 3 0 R /F 4 {} >>", enter("first;")),
|
||||
format!("<< /Type /Annot /Subtype /Widget /FT /Tx /T (dup) /V () /Rect [20 150 180 170] /P 3 0 R /F 4 {} >>", enter("second;")),
|
||||
]);
|
||||
let dir = std::env::temp_dir().join(format!("pdfcraft-js-event-dup-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
std::fs::write(dir.join("dup.pdf"), pdf).unwrap();
|
||||
let mut a = Automation::new().with_root(&dir).unwrap();
|
||||
let doc = tool(&mut a, "doc_open", json!({ "path": "dup.pdf" }))["doc"].as_u64().unwrap();
|
||||
let fields = tool(&mut a, "form_fields", json!({ "doc": doc }));
|
||||
let ids: Vec<u64> = fields["fields"].as_array().unwrap().iter().filter(|f| f["name"] == "dup").map(|f| f["field_id"].as_u64().unwrap()).collect();
|
||||
assert_eq!(ids.len(), 2, "{fields}");
|
||||
tool(&mut a, "js_event", json!({ "doc": doc, "field": "dup", "field_id": ids[1], "event": "mouse_enter" }));
|
||||
assert_eq!(log(&mut a, doc), "second;");
|
||||
tool(&mut a, "js_event", json!({ "doc": doc, "field": "dup", "event": "mouse_enter" }));
|
||||
assert_eq!(log(&mut a, doc), "second;first;");
|
||||
assert!(
|
||||
a.call("js_event", &json!({ "doc": doc, "field": "log", "field_id": ids[1], "event": "mouse_enter" })).is_err(),
|
||||
"the id is another field's"
|
||||
);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
@@ -9,6 +9,7 @@ mod batch;
|
||||
mod batch_failures;
|
||||
mod batch_secrets;
|
||||
mod button_caption;
|
||||
mod document_actions;
|
||||
mod find_text;
|
||||
mod form_bulk;
|
||||
mod hostile_args;
|
||||
|
||||
@@ -0,0 +1,379 @@
|
||||
//! Catalog JavaScript actions (#269, ported from #344): the `/OpenAction` runs once on opening
|
||||
//! and again on Revert, `/AA /WS` before a save and `/AA /WP` before printing, all in the
|
||||
//! document's retained realm.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::js::DocumentScriptEvent;
|
||||
use crate::{DocId, Session};
|
||||
|
||||
/// A PDF of `objs`, numbered from 1, with a cross-reference table.
|
||||
fn pdf_of(objs: &[String]) -> Vec<u8> {
|
||||
let mut out = b"%PDF-1.7\n".to_vec();
|
||||
let mut offsets = Vec::new();
|
||||
for (i, o) in objs.iter().enumerate() {
|
||||
offsets.push(out.len());
|
||||
out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes());
|
||||
}
|
||||
let xref = out.len();
|
||||
out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes());
|
||||
for o in offsets {
|
||||
out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes());
|
||||
}
|
||||
out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes());
|
||||
out
|
||||
}
|
||||
|
||||
/// A one-field form (`log`, a text field) whose catalog carries `catalog` (PDF syntax, e.g.
|
||||
/// `/OpenAction 6 0 R`), with `extra` objects numbered from 6.
|
||||
fn form(catalog: &str, extra: &[&str]) -> Vec<u8> {
|
||||
let mut objs = vec![
|
||||
format!("<< /Type /Catalog /Pages 2 0 R {catalog} /AcroForm << /Fields [4 0 R] /DA (/Helv 0 Tf 0 g) /DR << /Font << /Helv 5 0 R >> >> >> >>"),
|
||||
"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 200 300] >>".into(),
|
||||
"<< /Type /Page /Parent 2 0 R /Annots [4 0 R] >>".into(),
|
||||
"<< /Type /Annot /Subtype /Widget /FT /Tx /T (log) /V () /Rect [20 250 180 270] /P 3 0 R /F 4 >>".into(),
|
||||
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>".into(),
|
||||
];
|
||||
objs.extend(extra.iter().map(|s| s.to_string()));
|
||||
pdf_of(&objs)
|
||||
}
|
||||
|
||||
fn log(s: &Session, id: DocId) -> String {
|
||||
s.get(id).unwrap().form.iter().find(|f| f.name == "log").unwrap().value.join(",")
|
||||
}
|
||||
|
||||
/// The open action runs once on opening, after the document scripts and in their realm, and
|
||||
/// sees `event.name` "Open"; its field change is applied.
|
||||
#[test]
|
||||
fn the_open_action_runs_once_on_opening_in_the_document_realm() {
|
||||
let bytes = form(
|
||||
"/OpenAction 6 0 R /Names << /JavaScript << /Names [(init) 7 0 R] >> >>",
|
||||
&[
|
||||
"<< /S /JavaScript /JS (opened++; getField\\('log'\\).value = event.type + '/' + event.name + '/' + opened;) >>",
|
||||
"<< /S /JavaScript /JS (var opened = 0;) >>",
|
||||
],
|
||||
);
|
||||
let mut s = Session::new();
|
||||
let id = s.open("open.pdf", None, Arc::new(bytes), None).unwrap();
|
||||
assert_eq!(log(&s, id), "Doc/Open/1");
|
||||
assert!(s.take_js_output(id).errors.is_empty());
|
||||
// The realm is kept: a later script sees the count the open action left.
|
||||
assert_eq!(s.run_javascript(id, "opened;", None).unwrap().result.as_deref(), Some("1"));
|
||||
}
|
||||
|
||||
/// An open action chain runs in order through `/Next` (a dictionary or an array), skipping
|
||||
/// actions of other types, and a cycle in it runs each action once.
|
||||
#[test]
|
||||
fn an_open_action_chain_runs_in_order_and_a_cycle_runs_each_action_once() {
|
||||
let bytes = form(
|
||||
"/OpenAction 6 0 R",
|
||||
&[
|
||||
"<< /S /JavaScript /JS (var seen = ['a'];) /Next [7 0 R 8 0 R] >>",
|
||||
"<< /S /GoTo /D [3 0 R /Fit] /Next 9 0 R >>",
|
||||
"<< /S /JavaScript /JS (seen.push\\('c'\\); getField\\('log'\\).value = seen.join\\(''\\);) /Next 6 0 R >>",
|
||||
"<< /S /JavaScript /JS (seen.push\\('b'\\);) /Next 7 0 R >>",
|
||||
],
|
||||
);
|
||||
let mut s = Session::new();
|
||||
let id = s.open("chain.pdf", None, Arc::new(bytes), None).unwrap();
|
||||
assert_eq!(log(&s, id), "abc");
|
||||
}
|
||||
|
||||
/// What an open action asks for that needs a click (a URL, printing) is dropped; a page
|
||||
/// change it asks for is kept.
|
||||
#[test]
|
||||
fn an_open_action_may_not_open_a_url_or_print() {
|
||||
let bytes = form("/OpenAction << /S /JavaScript /JS (app.launchURL\\('https://example.com'\\); this.print\\(\\); this.pageNum = 0;) >>", &[]);
|
||||
let mut s = Session::new();
|
||||
let id = s.open("asks.pdf", None, Arc::new(bytes), None).unwrap();
|
||||
let out = s.take_js_output(id);
|
||||
assert_eq!(out.requests, [crate::js::Request::GoToPage(0)], "{out:?}");
|
||||
}
|
||||
|
||||
/// With JavaScript off the open action doesn't run.
|
||||
#[test]
|
||||
fn the_open_action_does_not_run_with_javascript_off() {
|
||||
let bytes = form("/OpenAction << /S /JavaScript /JS (getField\\('log'\\).value = 'ran';) >>", &[]);
|
||||
let mut s = Session::new();
|
||||
s.set_javascript(false);
|
||||
let id = s.open("off.pdf", None, Arc::new(bytes), None).unwrap();
|
||||
assert_eq!(log(&s, id), "");
|
||||
assert!(s.run_document_actions(id, DocumentScriptEvent::CatalogOpen).is_empty());
|
||||
}
|
||||
|
||||
/// Revert reopens the saved file in a fresh realm, so the open action runs again from scratch.
|
||||
#[test]
|
||||
fn revert_runs_the_open_action_again_in_a_fresh_realm() {
|
||||
let bytes = form(
|
||||
"/OpenAction << /S /JavaScript /JS (if \\(typeof runs == 'undefined'\\) var runs = 0; runs++; getField\\('log'\\).value = 'run ' + runs;) >>",
|
||||
&[],
|
||||
);
|
||||
let mut s = Session::new();
|
||||
let id = s.open("revert.pdf", None, Arc::new(bytes), None).unwrap();
|
||||
assert_eq!(log(&s, id), "run 1");
|
||||
s.run_javascript(id, "getField('log').value = 'typed';", None).unwrap();
|
||||
s.revert(id).unwrap();
|
||||
assert_eq!(log(&s, id), "run 1", "a fresh realm counts from zero again");
|
||||
}
|
||||
|
||||
/// Will Save runs in the document realm before the bytes are written, and what it changes is in
|
||||
/// the saved file; a failing Will Save is reported and the save still has bytes to write.
|
||||
#[test]
|
||||
fn will_save_changes_what_is_saved_and_a_failing_one_does_not_stop_the_save() {
|
||||
let bytes = form(
|
||||
"/Names << /JavaScript << /Names [(init) 6 0 R] >> >> /AA << /WS << /S /JavaScript /JS (saves++; getField\\('log'\\).value = event.name + ' ' + saves;) >> >>",
|
||||
&["<< /S /JavaScript /JS (var saves = 0;) >>"],
|
||||
);
|
||||
let mut s = Session::new();
|
||||
let id = s.open("save.pdf", None, Arc::new(bytes), None).unwrap();
|
||||
let out = s.run_document_actions(id, DocumentScriptEvent::WillSave);
|
||||
assert!(out.errors.is_empty(), "{out:?}");
|
||||
assert_eq!(log(&s, id), "WillSave 1");
|
||||
let saved = s.save_bytes(id).unwrap();
|
||||
let mut reopened = Session::new();
|
||||
reopened.set_javascript(false);
|
||||
let again = reopened.open("saved.pdf", None, saved, None).unwrap();
|
||||
assert_eq!(log(&reopened, again), "WillSave 1");
|
||||
|
||||
let failing = form("/AA << /WS << /S /JavaScript /JS (throw new Error\\('no'\\);) >> >>", &[]);
|
||||
let id = s.open("fail.pdf", None, Arc::new(failing), None).unwrap();
|
||||
let out = s.run_document_actions(id, DocumentScriptEvent::WillSave);
|
||||
assert!(out.errors.iter().any(|e| e.contains("no")), "{out:?}");
|
||||
assert!(s.save_bytes(id).is_ok());
|
||||
}
|
||||
|
||||
/// Will Print runs before printing, in the same realm, and sees `event.name` "WillPrint".
|
||||
#[test]
|
||||
fn will_print_runs_in_the_document_realm() {
|
||||
let bytes = form("/AA << /WP << /S /JavaScript /JS (getField\\('log'\\).value = event.name;) >> >>", &[]);
|
||||
let mut s = Session::new();
|
||||
let id = s.open("print.pdf", None, Arc::new(bytes), None).unwrap();
|
||||
assert!(s.run_document_actions(id, DocumentScriptEvent::WillSave).is_empty(), "no Will Save action: nothing runs");
|
||||
assert_eq!(log(&s, id), "");
|
||||
s.run_document_actions(id, DocumentScriptEvent::WillPrint);
|
||||
assert_eq!(log(&s, id), "WillPrint");
|
||||
}
|
||||
|
||||
/// A form with a text field `log` and a text field `hot` whose widget has JavaScript for every
|
||||
/// pointer and focus trigger, each appending `event.name` to `log`.
|
||||
fn triggers_form() -> Vec<u8> {
|
||||
let add =
|
||||
|| "<< /S /JavaScript /JS (var l = getField\\('log'\\); l.value = l.value + event.name + ':' + event.target.name + ';';) >>".to_string();
|
||||
let objs = vec![
|
||||
"<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [4 0 R 6 0 R] /DA (/Helv 0 Tf 0 g) /DR << /Font << /Helv 5 0 R >> >> >> >>".to_string(),
|
||||
"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 200 300] >>".into(),
|
||||
"<< /Type /Page /Parent 2 0 R /Annots [4 0 R 6 0 R] >>".into(),
|
||||
"<< /Type /Annot /Subtype /Widget /FT /Tx /T (log) /V () /Rect [20 250 180 270] /P 3 0 R /F 4 >>".into(),
|
||||
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>".into(),
|
||||
format!(
|
||||
"<< /Type /Annot /Subtype /Widget /FT /Tx /T (hot) /V () /Rect [20 200 180 220] /P 3 0 R /F 4 /AA << /E {} /X {} /D {} /U {} /Fo {} /Bl {} >> >>",
|
||||
add(),
|
||||
add(),
|
||||
"<< /S /JavaScript /JS (getField\\('log'\\).value += 'D;'; this.print\\(\\);) >>",
|
||||
add(),
|
||||
add(),
|
||||
"<< /S /JavaScript /JS (getField\\('log'\\).value += 'Bl;'; this.print\\(\\);) >>",
|
||||
),
|
||||
];
|
||||
pdf_of(&objs)
|
||||
}
|
||||
|
||||
/// Pointer and focus triggers run the widget's JavaScript with the event names Acrobat uses,
|
||||
/// each as one undoable edit; a field without such a script runs nothing.
|
||||
#[test]
|
||||
fn pointer_and_focus_triggers_run_the_widget_scripts() {
|
||||
use crate::js::FieldFocus;
|
||||
use pdfcraft_forms::Trigger;
|
||||
let mut s = Session::new();
|
||||
let id = s.open("hot.pdf", None, Arc::new(triggers_form()), None).unwrap();
|
||||
assert!(s.field_trigger(id, "hot", 0, Trigger::MouseEnter).unwrap());
|
||||
assert!(s.field_focus(id, "hot", 0, FieldFocus::Enter).unwrap());
|
||||
assert!(s.field_trigger(id, "hot", 0, Trigger::MouseUp).unwrap());
|
||||
assert!(s.field_trigger(id, "hot", 0, Trigger::MouseExit).unwrap());
|
||||
assert_eq!(log(&s, id), "Mouse Enter:hot;Focus:hot;Mouse Up:hot;Mouse Exit:hot;");
|
||||
assert_eq!(s.get(id).unwrap().can_undo(), Some("Run JavaScript"));
|
||||
assert!(!s.field_trigger(id, "log", 0, Trigger::MouseEnter).unwrap(), "no script: nothing runs");
|
||||
assert!(!s.field_trigger(id, "hot", 5, Trigger::MouseEnter).unwrap(), "no such widget: nothing runs");
|
||||
assert!(s.field_trigger(id, "missing", 0, Trigger::MouseEnter).is_err());
|
||||
s.set_javascript(false);
|
||||
assert!(!s.field_trigger(id, "hot", 0, Trigger::MouseEnter).unwrap(), "JavaScript off");
|
||||
}
|
||||
|
||||
/// A click's Mouse Down may ask to print; losing the focus, which happens unasked, may not.
|
||||
#[test]
|
||||
fn only_a_click_trigger_may_print() {
|
||||
use crate::js::{FieldFocus, Request};
|
||||
use pdfcraft_forms::Trigger;
|
||||
let mut s = Session::new();
|
||||
let id = s.open("hot.pdf", None, Arc::new(triggers_form()), None).unwrap();
|
||||
s.field_trigger(id, "hot", 0, Trigger::MouseDown).unwrap();
|
||||
assert_eq!(s.take_js_output(id).requests, [Request::Print]);
|
||||
s.field_focus(id, "hot", 0, FieldFocus::Exit).unwrap();
|
||||
let out = s.take_js_output(id);
|
||||
assert!(out.requests.is_empty(), "{out:?}");
|
||||
assert_eq!(log(&s, id), "D;Bl;");
|
||||
}
|
||||
|
||||
/// `setItems` and `setAction` are written to the document: the combo box gets its new items
|
||||
/// (pairs where text and export value differ), the installed scripts run on their triggers, and
|
||||
/// both survive a save (#269, from #344).
|
||||
#[test]
|
||||
fn set_items_and_set_action_are_written_to_the_document() {
|
||||
use pdfcraft_forms::Trigger;
|
||||
let objs = vec![
|
||||
"<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [4 0 R 6 0 R] /DA (/Helv 0 Tf 0 g) /DR << /Font << /Helv 5 0 R >> >> >> >>".to_string(),
|
||||
"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 200 300] >>".into(),
|
||||
"<< /Type /Page /Parent 2 0 R /Annots [4 0 R 6 0 R] >>".into(),
|
||||
"<< /Type /Annot /Subtype /Widget /FT /Tx /T (log) /V () /Rect [20 250 180 270] /P 3 0 R /F 4 >>".into(),
|
||||
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>".into(),
|
||||
"<< /Type /Annot /Subtype /Widget /FT /Ch /Ff 131072 /T (size) /Opt [(Small) (Large)] /V (Large) /Rect [20 200 180 220] /P 3 0 R /F 4 >>"
|
||||
.into(),
|
||||
];
|
||||
let mut s = Session::new();
|
||||
let id = s.open("items.pdf", None, Arc::new(pdf_of(&objs)), None).unwrap();
|
||||
let out = s
|
||||
.run_javascript(
|
||||
id,
|
||||
"var f = getField('size'); f.setItems([['Medium', 'm'], 'Large']); \
|
||||
f.setAction('MouseEnter', \"getField('log').value = 'entered ' + event.target.value;\");",
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(out.error, None, "{out:?}");
|
||||
let size = s.get(id).unwrap().form.iter().find(|f| f.name == "size").unwrap().clone();
|
||||
assert_eq!(size.options, [("m".to_string(), "Medium".to_string()), ("Large".into(), "Large".into())]);
|
||||
assert_eq!(size.value, ["Large"], "the value is still an item: it stays");
|
||||
assert!(s.field_trigger(id, "size", 0, Trigger::MouseEnter).unwrap());
|
||||
assert_eq!(log(&s, id), "entered Large");
|
||||
s.undo(id).unwrap();
|
||||
s.undo(id).unwrap();
|
||||
assert_eq!(s.get(id).unwrap().form.iter().find(|f| f.name == "size").unwrap().options[0].1, "Small", "undone");
|
||||
s.redo(id).unwrap();
|
||||
let saved = s.save_bytes(id).unwrap();
|
||||
let mut again = Session::new();
|
||||
let reopened = again.open("saved.pdf", None, saved, None).unwrap();
|
||||
let size = again.get(reopened).unwrap().form.iter().find(|f| f.name == "size").unwrap().clone();
|
||||
assert_eq!(size.options[0], ("m".to_string(), "Medium".to_string()));
|
||||
assert!(again.field_trigger(reopened, "size", 0, Trigger::MouseEnter).unwrap(), "the installed script is in the file");
|
||||
}
|
||||
|
||||
/// A check box's Mouse Up, run after the click stored the new value, reads that value and can
|
||||
/// show or hide another field by it (#247).
|
||||
#[test]
|
||||
fn a_check_box_mouse_up_reads_the_value_its_click_stored() {
|
||||
use crate::{Edit, FieldValue};
|
||||
use pdfcraft_forms::Trigger;
|
||||
let objs = vec![
|
||||
"<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [4 0 R 6 0 R 7 0 R] /DA (/Helv 0 Tf 0 g) /DR << /Font << /Helv 5 0 R >> >> >> >>".to_string(),
|
||||
"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 200 300] >>".into(),
|
||||
"<< /Type /Page /Parent 2 0 R /Annots [4 0 R 6 0 R 7 0 R] >>".into(),
|
||||
"<< /Type /Annot /Subtype /Widget /FT /Tx /T (log) /V () /Rect [20 250 180 270] /P 3 0 R /F 4 >>".into(),
|
||||
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>".into(),
|
||||
"<< /Type /Annot /Subtype /Widget /FT /Btn /T (strike) /V /Off /AS /Off /Rect [20 150 50 180] /P 3 0 R /F 4 /MK << /CA (4) >> /AP << /N << /Yes 8 0 R /Off 8 0 R >> >> /A << /S /JavaScript /JS (getField\\('note'\\).display = event.target.value == 'Off' ? display.hidden : display.visible; getField\\('log'\\).value += 'up:' + event.target.value + ';';) >> >>".into(),
|
||||
"<< /Type /Annot /Subtype /Widget /FT /Tx /T (note) /V (struck) /Rect [60 150 180 180] /P 3 0 R /F 6 >>".into(),
|
||||
"<< /Type /XObject /Subtype /Form /BBox [0 0 30 30] /Length 0 >>\nstream\n\nendstream".into(),
|
||||
];
|
||||
let mut s = Session::new();
|
||||
let id = s.open("strike.pdf", None, Arc::new(pdf_of(&objs)), None).unwrap();
|
||||
s.apply(id, Edit::SetFieldValue { name: "strike".into(), value: FieldValue::Text("Yes".into()) }).unwrap();
|
||||
let ran = s.field_trigger(id, "strike", 0, Trigger::MouseUp);
|
||||
let out = s.take_js_output(id);
|
||||
assert!(ran.unwrap(), "{out:?}");
|
||||
assert_eq!(log(&s, id), "up:Yes;");
|
||||
let note = |s: &Session| s.get(id).unwrap().form.iter().find(|f| f.name == "note").unwrap().widgets[0].hidden;
|
||||
assert!(!note(&s), "shown by the script");
|
||||
s.apply(id, Edit::SetFieldValue { name: "strike".into(), value: FieldValue::Check(false) }).unwrap();
|
||||
assert!(s.field_trigger(id, "strike", 0, Trigger::MouseUp).unwrap());
|
||||
assert_eq!(log(&s, id), "up:Yes;up:Off;");
|
||||
assert!(note(&s), "hidden again");
|
||||
}
|
||||
|
||||
/// A widget action's `/Next` chain runs too, each JavaScript action in order (depth first), past
|
||||
/// an action of another type at its head.
|
||||
#[test]
|
||||
fn a_widget_actions_next_chain_runs_each_script_in_order() {
|
||||
use pdfcraft_forms::Trigger;
|
||||
let js = |text: &str, next: &str| format!("<< /S /JavaScript /JS (getField\\('log'\\).value += '{text}';) {next} >>");
|
||||
let chain = format!("<< /S /URI /URI (https://example.com/) /Next [{} {}] >>", js("a;", &format!("/Next {}", js("b;", ""))), js("c;", ""));
|
||||
let objs = vec![
|
||||
"<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [4 0 R 6 0 R] /DA (/Helv 0 Tf 0 g) /DR << /Font << /Helv 5 0 R >> >> >> >>".to_string(),
|
||||
"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 200 300] >>".into(),
|
||||
"<< /Type /Page /Parent 2 0 R /Annots [4 0 R 6 0 R] >>".into(),
|
||||
"<< /Type /Annot /Subtype /Widget /FT /Tx /T (log) /V () /Rect [20 250 180 270] /P 3 0 R /F 4 >>".into(),
|
||||
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>".into(),
|
||||
format!("<< /Type /Annot /Subtype /Widget /FT /Tx /T (hot) /V () /Rect [20 200 180 220] /P 3 0 R /F 4 /AA << /E {chain} >> >>"),
|
||||
];
|
||||
let mut s = Session::new();
|
||||
let id = s.open("chain.pdf", None, Arc::new(pdf_of(&objs)), None).unwrap();
|
||||
assert!(s.field_trigger(id, "hot", 0, Trigger::MouseEnter).unwrap());
|
||||
assert_eq!(log(&s, id), "a;b;c;");
|
||||
}
|
||||
|
||||
/// `setAction` on a field that shares its name with another installs the script on that field,
|
||||
/// the one the script's object stands for, not the first of the name.
|
||||
#[test]
|
||||
fn set_action_installs_on_the_field_the_object_stands_for() {
|
||||
let objs = vec![
|
||||
"<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [4 0 R 6 0 R] /DA (/Helv 0 Tf 0 g) /DR << /Font << /Helv 5 0 R >> >> >> >>".to_string(),
|
||||
"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 200 300] >>".into(),
|
||||
"<< /Type /Page /Parent 2 0 R /Annots [4 0 R 6 0 R] >>".into(),
|
||||
"<< /Type /Annot /Subtype /Widget /FT /Tx /T (dup) /V () /Rect [20 250 180 270] /P 3 0 R /F 4 >>".into(),
|
||||
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>".into(),
|
||||
"<< /Type /Annot /Subtype /Widget /FT /Tx /T (dup) /V () /Rect [20 200 180 220] /P 3 0 R /F 4 >>".into(),
|
||||
];
|
||||
let mut s = Session::new();
|
||||
let id = s.open("dup.pdf", None, Arc::new(pdf_of(&objs)), None).unwrap();
|
||||
let second = crate::js::field_object_id(pdfcraft_cos::ObjRef::new(6, 0));
|
||||
let out =
|
||||
s.run_javascript_answers_target(id, "event.target.setAction('Format', \"event.value = 'x';\");", Some("dup"), Some(second), &[]).unwrap();
|
||||
assert_eq!(out.error, None, "{out:?}");
|
||||
let cos = s.get(id).unwrap().editor.as_ref().unwrap().cos.clone();
|
||||
let formats: Vec<(u32, bool)> = pdfcraft_forms::fields(&cos).iter().map(|f| (f.obj.num, f.actions.scripts.format.is_some())).collect();
|
||||
assert_eq!(formats, [(4, false), (6, true)]);
|
||||
}
|
||||
|
||||
/// Of two fields sharing a name, the one the pointer is on runs its own widget script.
|
||||
#[test]
|
||||
fn a_trigger_on_a_field_sharing_a_name_runs_that_fields_script() {
|
||||
use pdfcraft_forms::Trigger;
|
||||
let enter = |text: &str| format!("/AA << /E << /S /JavaScript /JS (getField\\('log'\\).value += '{text}';) >> >>");
|
||||
let objs = vec![
|
||||
"<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [4 0 R 6 0 R 7 0 R] /DA (/Helv 0 Tf 0 g) /DR << /Font << /Helv 5 0 R >> >> >> >>"
|
||||
.to_string(),
|
||||
"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 200 300] >>".into(),
|
||||
"<< /Type /Page /Parent 2 0 R /Annots [4 0 R 6 0 R 7 0 R] >>".into(),
|
||||
"<< /Type /Annot /Subtype /Widget /FT /Tx /T (log) /V () /Rect [20 250 180 270] /P 3 0 R /F 4 >>".into(),
|
||||
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>".into(),
|
||||
format!("<< /Type /Annot /Subtype /Widget /FT /Tx /T (dup) /V () /Rect [20 200 180 220] /P 3 0 R /F 4 {} >>", enter("first;")),
|
||||
format!("<< /Type /Annot /Subtype /Widget /FT /Tx /T (dup) /V () /Rect [20 150 180 170] /P 3 0 R /F 4 {} >>", enter("second;")),
|
||||
];
|
||||
let mut s = Session::new();
|
||||
let id = s.open("dup.pdf", None, Arc::new(pdf_of(&objs)), None).unwrap();
|
||||
let second = crate::js::field_object_id(pdfcraft_cos::ObjRef::new(7, 0));
|
||||
assert!(s.field_trigger_target(id, "dup", Some(second), 0, Trigger::MouseEnter).unwrap());
|
||||
assert_eq!(log(&s, id), "second;");
|
||||
assert!(s.field_trigger(id, "dup", 0, Trigger::MouseEnter).unwrap(), "by name alone, the first");
|
||||
assert_eq!(log(&s, id), "second;first;");
|
||||
}
|
||||
|
||||
/// Focus too: of two fields sharing a name, the one that gained focus runs its On Focus script.
|
||||
#[test]
|
||||
fn focus_on_a_field_sharing_a_name_runs_that_fields_script() {
|
||||
use crate::js::FieldFocus;
|
||||
let focus = |text: &str| format!("/AA << /Fo << /S /JavaScript /JS (getField\\('log'\\).value += '{text}';) >> >>");
|
||||
let objs = vec![
|
||||
"<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [4 0 R 6 0 R 7 0 R] /DA (/Helv 0 Tf 0 g) /DR << /Font << /Helv 5 0 R >> >> >> >>"
|
||||
.to_string(),
|
||||
"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 200 300] >>".into(),
|
||||
"<< /Type /Page /Parent 2 0 R /Annots [4 0 R 6 0 R 7 0 R] >>".into(),
|
||||
"<< /Type /Annot /Subtype /Widget /FT /Tx /T (log) /V () /Rect [20 250 180 270] /P 3 0 R /F 4 >>".into(),
|
||||
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>".into(),
|
||||
format!("<< /Type /Annot /Subtype /Widget /FT /Tx /T (dup) /V () /Rect [20 200 180 220] /P 3 0 R /F 4 {} >>", focus("first;")),
|
||||
format!("<< /Type /Annot /Subtype /Widget /FT /Tx /T (dup) /V () /Rect [20 150 180 170] /P 3 0 R /F 4 {} >>", focus("second;")),
|
||||
];
|
||||
let mut s = Session::new();
|
||||
let id = s.open("dup.pdf", None, Arc::new(pdf_of(&objs)), None).unwrap();
|
||||
let second = crate::js::field_object_id(pdfcraft_cos::ObjRef::new(7, 0));
|
||||
assert!(s.field_focus_target(id, "dup", Some(second), 0, FieldFocus::Enter).unwrap());
|
||||
assert_eq!(log(&s, id), "second;");
|
||||
}
|
||||
+185
-7
@@ -408,6 +408,8 @@ fn changes(
|
||||
text_color: a.text_color.as_deref().filter(|_| a.text_color != b.text_color).map(<[_]>::to_vec),
|
||||
fill_color: a.fill_color.as_deref().filter(|_| a.fill_color != b.fill_color).map(<[_]>::to_vec),
|
||||
caption: (a.caption != b.caption || (a.caption_written && b.caption_mixed)).then(|| a.caption.clone()).flatten(),
|
||||
options: (a.options != b.options).then(|| a.options.clone()),
|
||||
actions: a.actions.clone(),
|
||||
};
|
||||
(c.value.is_some()
|
||||
|| c.read_only.is_some()
|
||||
@@ -422,7 +424,9 @@ fn changes(
|
||||
|| c.line_width.is_some()
|
||||
|| c.border_style.is_some()
|
||||
|| c.user_name.is_some()
|
||||
|| c.icon.is_some())
|
||||
|| c.icon.is_some()
|
||||
|| c.options.is_some()
|
||||
|| !c.actions.is_empty())
|
||||
.then_some(c)
|
||||
})
|
||||
.collect()
|
||||
@@ -827,6 +831,23 @@ impl DocumentScriptEvent {
|
||||
Self::WillPrint => "WillPrint",
|
||||
}
|
||||
}
|
||||
|
||||
/// Where the catalog keeps this event's actions.
|
||||
fn trigger(self) -> pdfcraft_forms::DocumentTrigger {
|
||||
match self {
|
||||
Self::CatalogOpen => pdfcraft_forms::DocumentTrigger::Open,
|
||||
Self::WillSave => pdfcraft_forms::DocumentTrigger::WillSave,
|
||||
Self::WillPrint => pdfcraft_forms::DocumentTrigger::WillPrint,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Keep only what a script that ran unasked (on opening, before saving or printing) may ask
|
||||
/// the viewer for: highlighting, zoom, focus and the page shown. Opening a URL, a file, mail or
|
||||
/// a dialog needs a click.
|
||||
fn unasked_requests(requests: &mut Vec<Request>) {
|
||||
requests
|
||||
.retain(|r| matches!(r, Request::Highlight(_) | Request::Zoom(_) | Request::Focus(_) | Request::FocusField { .. } | Request::GoToPage(_)));
|
||||
}
|
||||
|
||||
impl Session {
|
||||
@@ -878,13 +899,33 @@ impl Session {
|
||||
/// change runs it) isn't run a second time. Returns whether the document changed; what the
|
||||
/// scripts showed or asked for is in [`Session::take_js_output`].
|
||||
pub fn field_focus(&mut self, id: DocId, name: &str, widget: usize, focus: FieldFocus) -> Result<bool, EditError> {
|
||||
self.field_focus_target(id, name, None, widget, focus)
|
||||
}
|
||||
|
||||
/// [`Session::field_focus`] for the field object `target` ([`field_object_id`]) when given:
|
||||
/// of fields sharing `name`, the one that gained or lost focus runs its own script.
|
||||
pub fn field_focus_target(&mut self, id: DocId, name: &str, target: Option<u64>, widget: usize, focus: FieldFocus) -> Result<bool, EditError> {
|
||||
if self.js_off {
|
||||
return Ok(false);
|
||||
}
|
||||
let doc = self.doc_mut(id)?;
|
||||
let Some(tpl) = doc.xfa_template.clone().filter(|t| pdfcraft_xfa::has_focus_scripts(t)) else { return Ok(false) };
|
||||
let Some(tpl) = doc.xfa_template.clone().filter(|t| pdfcraft_xfa::has_focus_scripts(t)) else {
|
||||
// An AcroForm widget's own On Focus / On Blur JavaScript (#269).
|
||||
let trigger = match focus {
|
||||
FieldFocus::Enter => pdfcraft_forms::Trigger::OnFocus,
|
||||
FieldFocus::Exit => pdfcraft_forms::Trigger::OnBlur,
|
||||
};
|
||||
return self.field_trigger_target(id, name, target, widget, trigger);
|
||||
};
|
||||
let Some(cos) = doc.editor.as_ref().map(|e| &e.cos) else { return Ok(false) };
|
||||
let Some(som) = doc.form.iter().find(|f| f.name == name).and_then(|f| crate::xfa::widget_som(cos, f, widget)) else { return Ok(false) };
|
||||
let Some(som) = doc
|
||||
.form
|
||||
.iter()
|
||||
.find(|f| f.name == name && target.is_none_or(|t| field_object_id(f.obj) == t))
|
||||
.and_then(|f| crate::xfa::widget_som(cos, f, widget))
|
||||
else {
|
||||
return Ok(false);
|
||||
};
|
||||
let activity = match focus {
|
||||
FieldFocus::Enter => "enter",
|
||||
FieldFocus::Exit => "exit",
|
||||
@@ -902,6 +943,85 @@ impl Session {
|
||||
Ok(self.get(id).is_some_and(|d| d.generation != before))
|
||||
}
|
||||
|
||||
/// Widget `widget` (0-based) of AcroForm field `name` in document `id` saw `trigger`: the
|
||||
/// pointer entered, left, pressed or released it, or it gained or lost the focus. Run that
|
||||
/// widget's JavaScript action for the trigger, if it has one, in the document's realm, with
|
||||
/// `event.type` "Field" and `event.name` "Mouse Enter", "Mouse Exit", "Mouse Down", "Mouse
|
||||
/// Up", "Focus" or "Blur" (#269, ported from #344), then those of the actions chained after
|
||||
/// it (`/Next`). What each action changes is one undoable edit.
|
||||
/// Returns whether the document changed; what the script showed or asked for is in
|
||||
/// [`Session::take_js_output`]. Pointer and focus scripts run unasked, so they may not
|
||||
/// print, open Save As or show a panel; Mouse Down and Mouse Up come from a click and may.
|
||||
/// Nothing runs on an XFA form (its own events run instead), with JavaScript off, or for a
|
||||
/// widget with no JavaScript for the trigger.
|
||||
pub fn field_trigger(&mut self, id: DocId, name: &str, widget: usize, trigger: pdfcraft_forms::Trigger) -> Result<bool, EditError> {
|
||||
self.field_trigger_target(id, name, None, widget, trigger)
|
||||
}
|
||||
|
||||
/// [`Session::field_trigger`] for the field object `target` ([`field_object_id`]) when given:
|
||||
/// of fields sharing `name`, the one the pointer is on runs its own script.
|
||||
pub fn field_trigger_target(
|
||||
&mut self,
|
||||
id: DocId,
|
||||
name: &str,
|
||||
target: Option<u64>,
|
||||
widget: usize,
|
||||
trigger: pdfcraft_forms::Trigger,
|
||||
) -> Result<bool, EditError> {
|
||||
use pdfcraft_forms::Trigger;
|
||||
if self.js_off {
|
||||
return Ok(false);
|
||||
}
|
||||
let doc = self.get(id).ok_or(EditError::NoDocument)?;
|
||||
if doc.info.xfa.is_some() {
|
||||
return Ok(false);
|
||||
}
|
||||
let Some(cos) = doc.editor.as_ref().map(|e| &e.cos) else { return Ok(false) };
|
||||
let field = doc
|
||||
.form
|
||||
.iter()
|
||||
.find(|f| f.name == name && target.is_none_or(|t| field_object_id(f.obj) == t))
|
||||
.ok_or_else(|| EditError::Invalid(format!("there is no field named {name:?}")))?;
|
||||
let Some(w) = field.widgets.get(widget) else { return Ok(false) };
|
||||
let scripts = pdfcraft_forms::widget_scripts(cos, w.obj, trigger);
|
||||
if scripts.is_empty() {
|
||||
return Ok(false);
|
||||
}
|
||||
let target_id = Some(field_object_id(field.obj));
|
||||
let event = match trigger {
|
||||
Trigger::OnFocus => "Focus",
|
||||
Trigger::OnBlur => "Blur",
|
||||
other => other.label(),
|
||||
};
|
||||
let before = doc.generation;
|
||||
let mut out = JsOutput::default();
|
||||
// Each action of a /Next chain runs on its own, as Acrobat runs them: one that fails
|
||||
// doesn't stop the next.
|
||||
for script in scripts {
|
||||
let o = match self.run_javascript_job(id, &script, (Some(name), target_id), &[], Some(event), false) {
|
||||
Ok(o) => o,
|
||||
Err(e) => {
|
||||
out.errors.push(e.to_string());
|
||||
continue;
|
||||
}
|
||||
};
|
||||
if matches!(trigger, Trigger::MouseUp | Trigger::MouseDown) {
|
||||
out.append(JsOutput {
|
||||
alerts: o.alerts,
|
||||
console: o.console,
|
||||
requests: o.requests,
|
||||
errors: o.error.into_iter().chain(o.doc_errors.iter().cloned()).collect(),
|
||||
doc_errors: o.doc_errors,
|
||||
});
|
||||
} else {
|
||||
out.absorb(&o);
|
||||
}
|
||||
}
|
||||
let doc = self.doc_mut(id)?;
|
||||
doc.js_output.append(out);
|
||||
Ok(doc.generation != before)
|
||||
}
|
||||
|
||||
/// Run signature field `field`'s XFA signing scripts for `event` in document `id`: `preSign`
|
||||
/// before it is signed, `postSign` after, as one undoable step, as a form's signing events run
|
||||
/// (DA 31's dates the signature and locks the date). `Ok(false)`, with nothing changed, when
|
||||
@@ -970,9 +1090,66 @@ impl Session {
|
||||
self.run_javascript_job(id, "", (None, None), &[], None, true)
|
||||
}
|
||||
|
||||
/// Opening document `id`: its document-level scripts run once, then the catalog
|
||||
/// `/OpenAction`'s JavaScript, in the document's retained realm (#269). Not on an XFA form
|
||||
/// (its own opening scripts run instead) or with JavaScript off. What they showed or asked
|
||||
/// for waits in [`Session::take_js_output`].
|
||||
pub(crate) fn run_open_scripts(&mut self, id: DocId) {
|
||||
if self.js_off {
|
||||
return;
|
||||
}
|
||||
let Some(doc) = self.get(id).filter(|d| d.info.xfa.is_none()) else { return };
|
||||
let has_document_scripts = !doc.document_scripts().is_empty();
|
||||
let opens =
|
||||
doc.editor.as_ref().map(|e| pdfcraft_forms::document_action_scripts(&e.cos, pdfcraft_forms::DocumentTrigger::Open)).unwrap_or_default();
|
||||
if !has_document_scripts && opens.is_empty() {
|
||||
return;
|
||||
}
|
||||
let mut out = JsOutput::default();
|
||||
if has_document_scripts && opens.is_empty() {
|
||||
match self.run_opening_javascript(id) {
|
||||
Ok(o) => out.absorb(&o),
|
||||
Err(e) => out.errors.push(e.to_string()),
|
||||
}
|
||||
}
|
||||
// The first open action initializes the realm with the document scripts too.
|
||||
out.append(self.run_actions(id, DocumentScriptEvent::CatalogOpen, &opens));
|
||||
unasked_requests(&mut out.requests);
|
||||
if let Some(doc) = self.docs.iter_mut().find(|d| d.id == id) {
|
||||
doc.js_output.append(out);
|
||||
}
|
||||
}
|
||||
|
||||
/// Run the catalog's JavaScript actions for document event `event` (`/AA /WS` before a save,
|
||||
/// `/AA /WP` before printing, `/OpenAction` on opening) in document `id`'s retained realm.
|
||||
/// Each action's field changes are one undoable edit. A failing action is reported in the
|
||||
/// output and the next one still runs; it never stops the save or print, which must not
|
||||
/// lose the user's work. Requests are limited to what an unasked script may make. With
|
||||
/// JavaScript off, on an XFA form, or with no such action, nothing runs.
|
||||
pub fn run_document_actions(&mut self, id: DocId, event: DocumentScriptEvent) -> JsOutput {
|
||||
if self.js_off {
|
||||
return JsOutput::default();
|
||||
}
|
||||
let Some(doc) = self.get(id).filter(|d| d.info.xfa.is_none()) else { return JsOutput::default() };
|
||||
let scripts = doc.editor.as_ref().map(|e| pdfcraft_forms::document_action_scripts(&e.cos, event.trigger())).unwrap_or_default();
|
||||
self.run_actions(id, event, &scripts)
|
||||
}
|
||||
|
||||
fn run_actions(&mut self, id: DocId, event: DocumentScriptEvent, scripts: &[String]) -> JsOutput {
|
||||
let mut out = JsOutput::default();
|
||||
for script in scripts {
|
||||
match self.run_javascript_job(id, script, (None, None), &[], Some(event.name()), event == DocumentScriptEvent::CatalogOpen) {
|
||||
Ok(o) => out.absorb(&o),
|
||||
Err(e) => out.errors.push(format!("{} script: {e}", event.name())),
|
||||
}
|
||||
}
|
||||
unasked_requests(&mut out.requests);
|
||||
out
|
||||
}
|
||||
|
||||
/// Run an explicitly dispatched PDF document action in the document's retained runtime.
|
||||
pub fn run_document_script_event(&mut self, id: DocId, event: DocumentScriptEvent, script: &str) -> Result<Outcome, EditError> {
|
||||
self.run_javascript_job(id, script, (None, None), &[], Some(event), false)
|
||||
self.run_javascript_job(id, script, (None, None), &[], Some(event.name()), false)
|
||||
}
|
||||
|
||||
/// Route generated XFA clicks to the XFA engine, or run a synchronous AcroForm job and apply its outcome.
|
||||
@@ -982,7 +1159,7 @@ impl Session {
|
||||
script: &str,
|
||||
(target, target_id): (Option<&str>, Option<u64>),
|
||||
answers: &[Option<String>],
|
||||
document_event: Option<DocumentScriptEvent>,
|
||||
event_name: Option<&'static str>,
|
||||
opening: bool,
|
||||
) -> Result<Outcome, EditError> {
|
||||
if self.js_off {
|
||||
@@ -993,6 +1170,7 @@ impl Session {
|
||||
validate_script_target(target, target_id, &doc.form)?;
|
||||
// A button PdfCraft generated from an XFA template runs its XFA click script.
|
||||
if let Some(t) = target
|
||||
&& event_name.is_none_or(|n| n == "Mouse Up")
|
||||
&& let Some(tpl) = doc.xfa_template.clone()
|
||||
&& let Some(field) = doc.form.iter().find(|f| f.name == t && target_id.is_none_or(|id| field_object_id(f.obj) == id))
|
||||
&& field.kind == crate::FormFieldKind::PushButton
|
||||
@@ -1015,8 +1193,8 @@ impl Session {
|
||||
let fields = pdfcraft_forms::fields(&cos);
|
||||
let states = std::sync::Arc::new(fields.iter().map(|f| field_state(&cos, f, &runner.doc)).collect::<Vec<_>>());
|
||||
let event = match target {
|
||||
Some(t) => pdfcraft_js::Event { will_commit: false, target_id, ..pdfcraft_js::Event::field("Mouse Up", t, "") },
|
||||
None => pdfcraft_js::Event::doc(document_event.map_or("Console", DocumentScriptEvent::name)),
|
||||
Some(t) => pdfcraft_js::Event { will_commit: false, target_id, ..pdfcraft_js::Event::field(event_name.unwrap_or("Mouse Up"), t, "") },
|
||||
None => pdfcraft_js::Event::doc(event_name.unwrap_or("Console")),
|
||||
};
|
||||
let calculations = calculation_scripts(&cos, &fields);
|
||||
let runtime = doc.js_runtime.clone();
|
||||
|
||||
@@ -200,5 +200,7 @@ fn display_config(config: &pdfcraft_render::RenderConfig) -> pdfcraft_render::Re
|
||||
/// How long a custom stamp's text line may be, in characters (#1741).
|
||||
pub const MAX_STAMP_LINE: usize = 200;
|
||||
|
||||
#[cfg(test)]
|
||||
mod document_actions_tests;
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -101,35 +101,7 @@ impl Session {
|
||||
Self::draw_captions(&mut document, self.captions.clone());
|
||||
}
|
||||
self.docs.push(document);
|
||||
if !self.js_off && self.get(id).is_some_and(|d| d.info.xfa.is_none() && !d.document_scripts().is_empty()) {
|
||||
match self.run_opening_javascript(id) {
|
||||
Ok(mut out) => {
|
||||
out.requests.retain(|r| {
|
||||
matches!(
|
||||
r,
|
||||
js::Request::Highlight(_)
|
||||
| js::Request::Zoom(_)
|
||||
| js::Request::Focus(_)
|
||||
| js::Request::FocusField { .. }
|
||||
| js::Request::GoToPage(_)
|
||||
)
|
||||
});
|
||||
if let Some(doc) = self.docs.iter_mut().find(|d| d.id == id) {
|
||||
doc.js_output.requests.extend(out.requests);
|
||||
doc.js_output.alerts.extend(out.alerts);
|
||||
doc.js_output.console.extend(out.console);
|
||||
doc.js_output.errors.extend(out.error);
|
||||
doc.js_output.doc_errors.extend(out.doc_errors.iter().cloned());
|
||||
doc.js_output.errors.extend(out.doc_errors);
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
if let Some(doc) = self.docs.iter_mut().find(|d| d.id == id) {
|
||||
doc.js_output.errors.push(e.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
self.run_open_scripts(id);
|
||||
id
|
||||
}
|
||||
|
||||
|
||||
@@ -132,7 +132,7 @@ impl Session {
|
||||
}
|
||||
|
||||
/// File ▸ Revert: back to the last saved version (or the file as opened). Undo history is
|
||||
/// cleared, as in Acrobat.
|
||||
/// cleared, as in Acrobat, and the document's opening scripts run again.
|
||||
pub fn revert(&mut self, id: DocId) -> Result<(), EditError> {
|
||||
self.invalidate_javascript(id);
|
||||
let doc = self.doc_mut(id)?;
|
||||
@@ -156,7 +156,11 @@ impl Session {
|
||||
// The changes whose exits ran are gone with the rest: skip no exit.
|
||||
doc.xfa_exited.clear();
|
||||
doc.generation += 1;
|
||||
Self::refresh(doc).inspect_err(|_| Self::show_released(doc))
|
||||
Self::refresh(doc).inspect_err(|_| Self::show_released(doc))?;
|
||||
// Reverting reopens the saved file, in a fresh realm: its opening scripts run again,
|
||||
// as they do on opening it (#269).
|
||||
self.run_open_scripts(id);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The document's file changed on disk (a LaTeX or Typst build rewrote it, #431): open
|
||||
|
||||
@@ -128,6 +128,21 @@ fn read(doc: &Document, o: &Object, pages: &[pdfcraft_cos::ObjRef], scripts: &mu
|
||||
})
|
||||
}
|
||||
|
||||
/// The JavaScript widget annotation `widget` runs for `trigger` (ISO 32000-2 Table 197: `/A`
|
||||
/// or `/AA /U` for Mouse Up, the other `/AA` entries for the rest): that action's, when it is a
|
||||
/// JavaScript action, then those of the actions chained after it with `/Next`, in the order
|
||||
/// they run (bounded as [`crate::document_action_scripts`] is). Reads only the widget's own
|
||||
/// dictionary, so a pointer moving over a large form costs nothing per field.
|
||||
pub fn widget_scripts(doc: &Document, widget: pdfcraft_cos::ObjRef, trigger: Trigger) -> Vec<String> {
|
||||
let w = doc.get(widget);
|
||||
let Some(wd) = w.as_dict() else { return Vec::new() };
|
||||
let action = match trigger.key() {
|
||||
None => wd.get(b"A").cloned().or_else(|| wd.get(b"AA").map(|a| doc.resolve(a)).and_then(|a| a.as_dict()?.get(b"U").cloned())),
|
||||
Some(k) => wd.get(b"AA").map(|a| doc.resolve(a)).and_then(|a| a.as_dict()?.get(k).cloned()),
|
||||
};
|
||||
crate::scripting::chained_scripts(doc, action)
|
||||
}
|
||||
|
||||
/// The actions of field `name` (its first widget), by trigger, in the tab's order.
|
||||
pub fn field_actions(doc: &Document, name: &str) -> Result<Vec<(Trigger, FieldAction)>, FormError> {
|
||||
let all = fields(doc);
|
||||
|
||||
@@ -24,15 +24,15 @@ mod calc_order;
|
||||
pub mod detect;
|
||||
mod scripting;
|
||||
pub mod table;
|
||||
pub use actions::{FieldAction, Trigger, field_action_chains, field_actions, set_field_actions};
|
||||
pub use actions::{FieldAction, Trigger, field_action_chains, field_actions, set_field_actions, widget_scripts};
|
||||
pub use author::{
|
||||
BorderStyle, CheckStyle, FieldFont, FieldProps, Look, LookPatch, MIN_FIELD_SIDE, NewField, add_field, check_style, delete_field,
|
||||
delete_field_object, delete_widget, duplicate_field, look, redraw_field, redraw_fields, set_button_icon, set_button_icon_target, set_props,
|
||||
};
|
||||
pub use calc_order::{CalculatedField, CalculationIssue, CalculationOrder, calculation_order, set_calculation_order};
|
||||
pub use scripting::{
|
||||
FieldChange, FieldEvent, NoScripts, ScriptResult, Scripts, apply_script_changes, document_scripts, document_scripts_named, set_document_script,
|
||||
set_field_script,
|
||||
DocumentTrigger, FieldChange, FieldEvent, NoScripts, ScriptResult, Scripts, apply_script_changes, document_action_scripts, document_scripts,
|
||||
document_scripts_named, set_document_script, set_field_script,
|
||||
};
|
||||
|
||||
mod appearances;
|
||||
|
||||
@@ -57,6 +57,11 @@ pub struct FieldChange {
|
||||
pub border_style: Option<String>,
|
||||
pub user_name: Option<String>,
|
||||
pub icon: Option<Option<u64>>,
|
||||
/// A combo box's or list box's new items (export value, text), from `setItems`.
|
||||
pub options: Option<Vec<(String, String)>>,
|
||||
/// 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.
|
||||
@@ -171,6 +176,73 @@ fn protect_other_mk_owners(doc: &mut Document, field: &Field, owners: &std::coll
|
||||
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<String> {
|
||||
@@ -211,6 +283,19 @@ pub(crate) fn apply_changes(
|
||||
|
||||
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
|
||||
@@ -454,6 +539,79 @@ pub fn document_scripts_named(doc: &Document) -> Vec<(String, String)> {
|
||||
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<String> {
|
||||
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<Object>) -> Vec<String> {
|
||||
let mut budget = crate::script_budget();
|
||||
let mut seen: HashSet<u32> = HashSet::new();
|
||||
let mut pending: Vec<Object> = 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])) {
|
||||
@@ -511,7 +669,12 @@ pub fn set_document_script(doc: &mut Document, name: &str, js: Option<&str>) ->
|
||||
/// (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 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"));
|
||||
|
||||
@@ -279,7 +279,71 @@ fn field_object_new(ctx: &mut Context, name: &str, id: Option<u64>, token: u64)
|
||||
Ok(JsValue::undefined())
|
||||
}
|
||||
|
||||
/// `setAction(cTrigger, cScript)`: install `cScript` as the field's JavaScript for
|
||||
/// `cTrigger`, replacing what it ran before; saved with the document (#269).
|
||||
fn set_action(_: &JsValue, args: &[JsValue], name: &FieldKey, c: &mut Context) -> JsResult<JsValue> {
|
||||
let trigger = text(&arg(args, 0), c)?;
|
||||
if !SET_ACTION_TRIGGERS.contains(&trigger.as_str()) {
|
||||
return Err(error(format!("setAction: unknown trigger {trigger:?}; use one of {}", SET_ACTION_TRIGGERS.join(", "))));
|
||||
}
|
||||
let script = text(&arg(args, 1), c)?;
|
||||
if let Some(why) = crate::run::refuse(&script) {
|
||||
return Err(error(format!("setAction: {why}")));
|
||||
}
|
||||
write(c, name, |f| {
|
||||
f.actions.retain(|(t, _)| *t != trigger);
|
||||
f.actions.push((trigger, script));
|
||||
})?;
|
||||
Ok(JsValue::undefined())
|
||||
}
|
||||
/// `setItems(oArray)`: replace a combo box's or list box's items. Each item is its text, or
|
||||
/// a `[text, exportValue]` pair. The value stays when an item still has it, and otherwise
|
||||
/// becomes the first item's (#269).
|
||||
fn set_items(_: &JsValue, args: &[JsValue], name: &FieldKey, c: &mut Context) -> JsResult<JsValue> {
|
||||
let f = read(c, name)?;
|
||||
if !matches!(f.kind, FieldType::ComboBox | FieldType::ListBox) {
|
||||
return Err(error(format!("setItems: {} is not a combo box or list box", f.name)));
|
||||
}
|
||||
let list = arg(args, 0).as_object().filter(JsObject::is_array).ok_or_else(|| error("setItems needs an array of items"))?;
|
||||
let list = JsArray::from_object(list)?;
|
||||
let len = list.length(c)?;
|
||||
if len > MAX_ITEMS as u64 {
|
||||
return Err(error(format!("setItems: at most {MAX_ITEMS} items")));
|
||||
}
|
||||
let mut items = Vec::new();
|
||||
let mut bytes = 0usize;
|
||||
for i in 0..len {
|
||||
let item = list.get(i, c)?;
|
||||
let (label, export) = match item.as_object().filter(JsObject::is_array) {
|
||||
Some(pair) => {
|
||||
let pair = JsArray::from_object(pair.clone())?;
|
||||
let label = text(&pair.get(0, c)?, c)?;
|
||||
let export = pair.get(1, c)?;
|
||||
let export = if export.is_undefined() { label.clone() } else { text(&export, c)? };
|
||||
(label, export)
|
||||
}
|
||||
None => {
|
||||
let label = text(&item, c)?;
|
||||
(label.clone(), label)
|
||||
}
|
||||
};
|
||||
bytes = bytes.saturating_add(label.len()).saturating_add(export.len());
|
||||
if bytes > MAX_ITEM_BYTES {
|
||||
return Err(error("setItems: the items are too long"));
|
||||
}
|
||||
items.push((export, label));
|
||||
}
|
||||
write(c, name, |f| {
|
||||
let kept: Vec<String> = f.value.iter().filter(|v| items.iter().any(|(export, _)| export == *v)).cloned().collect();
|
||||
f.value = if kept.is_empty() { items.first().map(|(export, _)| vec![export.clone()]).unwrap_or_default() } else { kept };
|
||||
f.options = items;
|
||||
})?;
|
||||
Ok(JsValue::undefined())
|
||||
}
|
||||
|
||||
let rw = Attribute::CONFIGURABLE | Attribute::ENUMERABLE;
|
||||
let set_action = NativeFunction::from_copy_closure_with_captures(set_action, n.clone());
|
||||
let set_items = NativeFunction::from_copy_closure_with_captures(set_items, n.clone());
|
||||
let get_item_at = NativeFunction::from_copy_closure_with_captures(get_item_at, n.clone());
|
||||
let is_box_checked = NativeFunction::from_copy_closure_with_captures(is_box_checked, n.clone());
|
||||
let check_this_box = NativeFunction::from_copy_closure_with_captures(check_this_box, n.clone());
|
||||
@@ -314,6 +378,8 @@ fn field_object_new(ctx: &mut Context, name: &str, id: Option<u64>, token: u64)
|
||||
.function(is_box_checked, js_string!("isBoxChecked"), 1)
|
||||
.function(check_this_box, js_string!("checkThisBox"), 2)
|
||||
.function(set_focus, js_string!("setFocus"), 0)
|
||||
.function(set_action, js_string!("setAction"), 2)
|
||||
.function(set_items, js_string!("setItems"), 1)
|
||||
.function(button_get_icon, js_string!("buttonGetIcon"), 1)
|
||||
.function(button_set_icon, js_string!("buttonSetIcon"), 2)
|
||||
.function(button_get_caption, js_string!("buttonGetCaption"), 1)
|
||||
@@ -321,6 +387,14 @@ fn field_object_new(ctx: &mut Context, name: &str, id: Option<u64>, token: u64)
|
||||
.build()
|
||||
}
|
||||
|
||||
/// The triggers `field.setAction` takes (Acrobat JavaScript reference, Field.setAction).
|
||||
const SET_ACTION_TRIGGERS: [&str; 10] =
|
||||
["MouseUp", "MouseDown", "MouseEnter", "MouseExit", "OnFocus", "OnBlur", "Keystroke", "Validate", "Calculate", "Format"];
|
||||
/// Most items one `setItems` call takes, and most bytes of their texts and export values: a
|
||||
/// script must not grow a field without bound.
|
||||
const MAX_ITEMS: usize = 10_000;
|
||||
const MAX_ITEM_BYTES: usize = 1 << 20;
|
||||
|
||||
// ── the document (global object) ────────────────────────────────────────────────────────────
|
||||
|
||||
pub(crate) fn get_field(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult<JsValue> {
|
||||
|
||||
@@ -591,6 +591,7 @@ pub(crate) fn install(ctx: &mut Context) -> JsResult<()> {
|
||||
ctx.register_global_property(js_string!("display"), display, rw)?;
|
||||
let color = color_object(ctx);
|
||||
ctx.register_global_property(js_string!("color"), color, rw)?;
|
||||
crate::source::install(ctx)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
@@ -44,6 +44,7 @@ mod host;
|
||||
mod model;
|
||||
mod nesting;
|
||||
mod run;
|
||||
mod source;
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) use format::PRINTD_SCANNED;
|
||||
|
||||
@@ -74,6 +74,11 @@ pub struct FieldState {
|
||||
pub caption_mixed: bool,
|
||||
pub icon_mixed: bool,
|
||||
pub icon_written: bool,
|
||||
/// JavaScript actions a script installed with `setAction` during this run, by trigger
|
||||
/// (`MouseUp`, `MouseDown`, `MouseEnter`, `MouseExit`, `OnFocus`, `OnBlur`, `Keystroke`,
|
||||
/// `Validate`, `Calculate`, `Format`), the last one for each trigger. Empty in a snapshot:
|
||||
/// the host reads the document's own actions from the PDF.
|
||||
pub actions: Vec<(String, String)>,
|
||||
}
|
||||
|
||||
impl FieldState {
|
||||
@@ -108,6 +113,7 @@ impl FieldState {
|
||||
caption_mixed: false,
|
||||
icon_mixed: false,
|
||||
icon_written: false,
|
||||
actions: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
//! `toSource()` on objects and arrays, which older forms use to keep state in a field or a
|
||||
//! global as text (#269, ported from #344). It writes a JavaScript literal of the value; scripts
|
||||
//! here can't `eval` it back, since strings never compile. Plain data only: strings, numbers,
|
||||
//! booleans, null, undefined, arrays and objects of them. Functions and cycles are refused, and
|
||||
//! so is a value past the depth, size or length bounds, rather than writing something that
|
||||
//! reads as another value.
|
||||
|
||||
use super::host::{error, function, s, text};
|
||||
use super::{Context, JsArray, JsObject, JsResult, JsValue, NativeFunction, js_string};
|
||||
use boa_engine::property::{PropertyDescriptor, PropertyKey};
|
||||
|
||||
/// Deepest nesting written.
|
||||
const MAX_DEPTH: usize = 64;
|
||||
/// Most values (each array item, property and the value itself) written.
|
||||
const MAX_VALUES: usize = 10_000;
|
||||
/// Longest source written, in bytes.
|
||||
const MAX_BYTES: usize = 1 << 20;
|
||||
|
||||
/// Install `toSource` on `Object.prototype` (arrays inherit it), not enumerable, as in the
|
||||
/// engines that had it.
|
||||
pub(crate) fn install(ctx: &mut Context) -> JsResult<()> {
|
||||
let to_source = function(ctx, NativeFunction::from_fn_ptr(to_source));
|
||||
let prototype = ctx.intrinsics().constructors().object().prototype();
|
||||
prototype
|
||||
.insert_property(js_string!("toSource"), PropertyDescriptor::builder().value(to_source).writable(true).configurable(true).enumerable(false));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn to_source(this: &JsValue, _: &[JsValue], ctx: &mut Context) -> JsResult<JsValue> {
|
||||
let mut writer = Writer { values: MAX_VALUES, bytes: MAX_BYTES, open: Vec::new() };
|
||||
let out = writer.value(this, ctx)?;
|
||||
Ok(s(&out))
|
||||
}
|
||||
|
||||
/// What is left of the bounds, and the objects being written (to refuse a cycle).
|
||||
struct Writer {
|
||||
values: usize,
|
||||
bytes: usize,
|
||||
open: Vec<JsObject>,
|
||||
}
|
||||
|
||||
impl Writer {
|
||||
fn value(&mut self, v: &JsValue, ctx: &mut Context) -> JsResult<String> {
|
||||
self.values = self.values.checked_sub(1).ok_or_else(|| error("toSource: the value has too many parts"))?;
|
||||
let out = if v.is_callable() {
|
||||
return Err(error("toSource: functions can't be written"));
|
||||
} else if let Some(o) = v.as_object() {
|
||||
self.object(&o, ctx)?
|
||||
} else if let Some(text) = v.as_string() {
|
||||
quote(&text.to_std_string_escaped())
|
||||
} else if v.is_null() {
|
||||
"null".into()
|
||||
} else if v.is_undefined() {
|
||||
"undefined".into()
|
||||
} else {
|
||||
text(v, ctx)?
|
||||
};
|
||||
self.charge(out.len())?;
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn object(&mut self, o: &JsObject, ctx: &mut Context) -> JsResult<String> {
|
||||
if self.open.iter().any(|open| open == o) {
|
||||
return Err(error("toSource: the value refers to itself"));
|
||||
}
|
||||
if self.open.len() >= MAX_DEPTH {
|
||||
return Err(error("toSource: the value is nested too deeply"));
|
||||
}
|
||||
if !o.is_array() && !plain(o, ctx) {
|
||||
return Err(error("toSource: only plain objects, arrays, strings, numbers, booleans, null and undefined can be written"));
|
||||
}
|
||||
self.open.push(o.clone());
|
||||
let out = if o.is_array() { self.array(o, ctx) } else { self.fields(o, ctx) };
|
||||
self.open.pop();
|
||||
out
|
||||
}
|
||||
|
||||
fn array(&mut self, o: &JsObject, ctx: &mut Context) -> JsResult<String> {
|
||||
let array = JsArray::from_object(o.clone())?;
|
||||
let len = array.length(ctx)?;
|
||||
if len > self.values as u64 {
|
||||
return Err(error("toSource: the array is too long"));
|
||||
}
|
||||
let mut items = Vec::new();
|
||||
for i in 0..len {
|
||||
items.push(self.value(&array.get(i, ctx)?, ctx)?);
|
||||
}
|
||||
Ok(format!("[{}]", items.join(", ")))
|
||||
}
|
||||
|
||||
fn fields(&mut self, o: &JsObject, ctx: &mut Context) -> JsResult<String> {
|
||||
let mut items = Vec::new();
|
||||
for key in o.own_property_keys(ctx)? {
|
||||
let label = match &key {
|
||||
PropertyKey::Symbol(_) => continue,
|
||||
PropertyKey::String(k) => k.to_std_string_escaped(),
|
||||
PropertyKey::Index(i) => i.get().to_string(),
|
||||
};
|
||||
// `"__proto__": v` in a literal sets the prototype rather than an own property.
|
||||
if label == "__proto__" {
|
||||
return Err(error("toSource: a property named __proto__ can't be written"));
|
||||
}
|
||||
self.charge(label.len())?;
|
||||
let value = self.value(&o.get(key, ctx)?, ctx)?;
|
||||
items.push(format!("{}:{value}", quote(&label)));
|
||||
}
|
||||
Ok(format!("({{{}}})", items.join(", ")))
|
||||
}
|
||||
|
||||
/// Count `n` bytes against the length bound. Every part counts, including the parts of what
|
||||
/// is joined later, so a value repeated many times can't build a long result first.
|
||||
fn charge(&mut self, n: usize) -> JsResult<()> {
|
||||
self.bytes = self.bytes.checked_sub(n).ok_or_else(|| error("toSource: the result would be too long"))?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `o` is a plain object: an ordinary one whose prototype is `Object.prototype` (or
|
||||
/// none), so its own properties are all there is to it. A Date, a RegExp, a boxed value, a
|
||||
/// proxy or a class instance holds more than a literal of its properties would give back.
|
||||
fn plain(o: &JsObject, ctx: &Context) -> bool {
|
||||
let object_prototype = ctx.intrinsics().constructors().object().prototype();
|
||||
o.is_ordinary() && o.prototype().is_none_or(|p| p == object_prototype)
|
||||
}
|
||||
|
||||
/// `text` as a double-quoted JavaScript string literal.
|
||||
fn quote(text: &str) -> String {
|
||||
let mut out = String::with_capacity(text.len() + 2);
|
||||
out.push('"');
|
||||
for c in text.chars() {
|
||||
match c {
|
||||
'\\' => out.push_str("\\\\"),
|
||||
'"' => out.push_str("\\\""),
|
||||
'\n' => out.push_str("\\n"),
|
||||
'\r' => out.push_str("\\r"),
|
||||
'\t' => out.push_str("\\t"),
|
||||
'\u{2028}' => out.push_str("\\u2028"),
|
||||
'\u{2029}' => out.push_str("\\u2029"),
|
||||
c if c.is_control() => out.push_str(&format!("\\u{:04x}", c as u32)),
|
||||
c => out.push(c),
|
||||
}
|
||||
}
|
||||
out.push('"');
|
||||
out
|
||||
}
|
||||
@@ -2702,3 +2702,76 @@ fn af_simple_calculate_bounds_selector_work_before_expansion() {
|
||||
run("AFSimple_Calculate('SUM',new Array(4096).fill('n'));", &Event::doc("Console"), &Default::default(), &fields, &[], Default::default());
|
||||
assert!(out.error.as_deref().is_some_and(|e| e.contains("calculation field lookup exceeds")));
|
||||
}
|
||||
|
||||
/// `toSource` writes plain state as a JavaScript literal of the same value (#269, from #344).
|
||||
/// Scripts here can't `eval` it back (strings never compile), so the exact text is checked.
|
||||
#[test]
|
||||
fn to_source_writes_plain_state_as_a_literal() {
|
||||
let event = Event::doc("Console");
|
||||
let out = go(
|
||||
"var v = {tab: 2, name: 'a \"q\"\\n', on: true, none: null, list: ['qty', null, undefined, 1.5, [3]]}; \
|
||||
[v.toSource(), [1, 'x'].toSource(), ({}).toSource()].join('|');",
|
||||
&event,
|
||||
);
|
||||
assert_eq!(out.error, None, "{out:?}");
|
||||
assert_eq!(
|
||||
out.result.as_deref(),
|
||||
Some(r#"({"tab":2, "name":"a \"q\"\n", "on":true, "none":null, "list":["qty", null, undefined, 1.5, [3]]})|[1, "x"]|({})"#)
|
||||
);
|
||||
assert!(go("Object.keys({}).indexOf('toSource') < 0 && !({}).propertyIsEnumerable('toSource');", &event).result.as_deref() == Some("true"));
|
||||
}
|
||||
|
||||
/// `toSource` refuses what it can't write faithfully or within its bounds, with an error the
|
||||
/// script can catch.
|
||||
#[test]
|
||||
fn to_source_refuses_cycles_functions_and_oversized_state() {
|
||||
let event = Event::doc("Console");
|
||||
for (script, expected) in [
|
||||
("var v = {}; v.v = v; v.toSource();", "refers to itself"),
|
||||
("({f: function () {}}).toSource();", "functions"),
|
||||
("var v = []; v.length = 1000000; v.toSource();", "too long"),
|
||||
("var v = []; for (var i = 0; i < 100; i++) v = [v]; v.toSource();", "nested too deeply"),
|
||||
("var s = new Array(4097).join('x'); var v = []; for (var i = 0; i < 300; i++) v.push(s); v.toSource();", "too long"),
|
||||
("({when: new Date(0)}).toSource();", "only plain objects"),
|
||||
("[/x/].toSource();", "only plain objects"),
|
||||
("function C() { this.a = 1; } new C().toSource();", "only plain objects"),
|
||||
("var v = JSON.parse('{\"__proto__\": 1}'); v.toSource();", "__proto__"),
|
||||
] {
|
||||
let out = go(script, &event);
|
||||
assert!(out.error.as_deref().is_some_and(|e| e.contains(expected)), "{script}: {out:?}");
|
||||
}
|
||||
let out = go("try { var v = {}; v.v = v; v.toSource(); 'no'; } catch (e) { 'caught'; }", &event);
|
||||
assert_eq!(out.result.as_deref(), Some("caught"));
|
||||
}
|
||||
|
||||
/// `setItems` replaces a choice field's items (text, or [text, export] pairs), keeping the value
|
||||
/// when an item still has it; `setAction` records the script for its trigger, the last one
|
||||
/// winning. Both refuse what they can't do.
|
||||
#[test]
|
||||
fn set_items_and_set_action_change_the_field() {
|
||||
let event = Event::doc("Console");
|
||||
let out = go(
|
||||
"var f = getField('size'); f.setItems([['Medium', 'm'], 'Huge', ['Tiny']]); \
|
||||
f.setAction('OnFocus', 'console.println(1)'); f.setAction('OnFocus', 'console.println(2)'); f.setAction('Keystroke', 'event.rc = true;'); \
|
||||
[f.numItems, f.getItemAt(1, true), f.getItemAt(2, false), f.value].join('|');",
|
||||
&event,
|
||||
);
|
||||
assert_eq!(out.error, None, "{out:?}");
|
||||
assert_eq!(out.result.as_deref(), Some("3|Huge|Tiny|m"));
|
||||
let size = out.changed.iter().find(|f| f.name == "size").unwrap();
|
||||
assert_eq!(size.options, [("m".into(), "Medium".into()), ("Huge".into(), "Huge".into()), ("Tiny".into(), "Tiny".into())]);
|
||||
assert_eq!(size.actions, [("OnFocus".into(), "console.println(2)".into()), ("Keystroke".into(), "event.rc = true;".into())]);
|
||||
|
||||
let out = go("var f = getField('size'); f.setItems(['a', 'b']); f.value;", &event);
|
||||
assert_eq!(out.result.as_deref(), Some("a"), "the old value is gone: the first item's");
|
||||
|
||||
for (script, expected) in [
|
||||
("getField('price').setItems(['a']);", "not a combo box or list box"),
|
||||
("getField('size').setItems('a');", "needs an array"),
|
||||
("var a = []; a.length = 20000; getField('size').setItems(a);", "at most"),
|
||||
("getField('size').setAction('Explode', 'x');", "unknown trigger"),
|
||||
] {
|
||||
let out = go(script, &event);
|
||||
assert!(out.error.as_deref().is_some_and(|e| e.contains(expected)), "{script}: {out:?}");
|
||||
}
|
||||
}
|
||||
@@ -1448,6 +1448,8 @@ struct PageFrame<'a> {
|
||||
current_height: f32,
|
||||
pressed_page: Option<usize>,
|
||||
calc_hover: Option<crate::calc_order_ui::Focus>,
|
||||
/// The form field widget under the pointer on any page, for its Mouse Enter and Exit scripts.
|
||||
field_hover: Option<crate::forms_ui::WidgetRef>,
|
||||
/// A page took this frame's right-click.
|
||||
right_clicked: bool,
|
||||
}
|
||||
@@ -1930,6 +1932,7 @@ fn draw_pages(
|
||||
current_height: f32::INFINITY,
|
||||
pressed_page: None,
|
||||
calc_hover: None,
|
||||
field_hover: None,
|
||||
right_clicked: false,
|
||||
};
|
||||
for &i in visible_pages {
|
||||
@@ -1944,10 +1947,12 @@ fn draw_pages(
|
||||
current_height,
|
||||
pressed_page,
|
||||
calc_hover,
|
||||
field_hover,
|
||||
out: page_out,
|
||||
right_clicked,
|
||||
..
|
||||
} = frame;
|
||||
crate::forms_ui::hover_moved(view, field_hover);
|
||||
// A right-click no page took deselects the comment, so the page's menu opens, not the comment's,
|
||||
// and with Edit text the picture (one on a page went to that page, `page_input`).
|
||||
if resp.secondary_clicked() && !right_clicked {
|
||||
|
||||
@@ -966,6 +966,9 @@ pub(super) fn page_overlays(ui: &mut egui::Ui, view: &mut DocView, frame: &mut P
|
||||
} else {
|
||||
crate::forms_ui::paint_page(painter, &xf, i, info, form, view, input.hover.as_ref());
|
||||
crate::forms_ui::tooltip(resp, form, input.hover.as_ref());
|
||||
if let Some(hovered) = crate::forms_ui::hovered(form, input.hover.as_ref()) {
|
||||
frame.field_hover = Some(hovered);
|
||||
}
|
||||
}
|
||||
|
||||
// Form-field highlight (Acrobat's "Highlight existing fields"); required fields get a
|
||||
|
||||
@@ -524,10 +524,22 @@ impl PdfCraftApp {
|
||||
// click that brought this pane forward took the focus). It goes in first, as it came first.
|
||||
let (other_applied, stay) = self.apply_other_pane_edit(i);
|
||||
let modal = self.modal_open();
|
||||
let generation = |app: &Self| app.views.get(i).and_then(|v| app.session.get(v.id)).map(|d| d.edit_generation());
|
||||
let before = generation(self);
|
||||
let queued = self.views.get(i).is_some_and(|v| v.pending_edit.is_some());
|
||||
let applied = self.apply_queued_edit(i) && other_applied;
|
||||
// A click's Mouse Up waits on the edit it queued (a check box's toggle, a list's pick):
|
||||
// when that was refused, the click did nothing and its Mouse Up doesn't run either.
|
||||
if queued
|
||||
&& generation(self) == before
|
||||
&& let Some(view) = self.views.get_mut(i)
|
||||
{
|
||||
view.forms.triggers.retain(|(_, t)| *t != pdfcraft_engine::FieldTrigger::MouseUp);
|
||||
}
|
||||
// After the queued edit: a value typed into the field the focus left has been taken (and
|
||||
// its change has run the field's exit) before the focus scripts run.
|
||||
self.run_focus_scripts(i);
|
||||
self.run_field_triggers(i);
|
||||
if !applied {
|
||||
// Nor may a shortcut pressed with it (⌘S right after Enter) run behind the refusal.
|
||||
self.deferred_commands.clear();
|
||||
@@ -818,7 +830,8 @@ impl PdfCraftApp {
|
||||
/// ([`crate::forms_ui::refresh_draft`]), so leaving the field doesn't write the old value back.
|
||||
fn run_focus_scripts(&mut self, i: usize) {
|
||||
let Some(view) = self.views.get_mut(i) else { return };
|
||||
let now = view.forms.focus.as_ref().map(|f| (f.name.clone(), f.widget));
|
||||
// The field's object too: moving between fields sharing a name is a move (#344 review).
|
||||
let now = view.forms.focus.as_ref().map(|f| (f.name.clone(), f.obj, f.widget));
|
||||
if view.forms.focused == now {
|
||||
return;
|
||||
}
|
||||
@@ -826,13 +839,13 @@ impl PdfCraftApp {
|
||||
let id = view.id;
|
||||
let edited = view.forms.focus.clone();
|
||||
use pdfcraft_engine::js::FieldFocus;
|
||||
for ((name, widget), focus) in [left.map(|l| (l, FieldFocus::Exit)), now.map(|n| (n, FieldFocus::Enter))].into_iter().flatten() {
|
||||
for ((name, obj, widget), focus) in [left.map(|l| (l, FieldFocus::Exit)), now.map(|n| (n, FieldFocus::Enter))].into_iter().flatten() {
|
||||
// The edited field's value before this script, to tell its untouched draft from typing:
|
||||
// the exit of the field left may have changed it already, and the draft with it.
|
||||
let before: Option<(String, Vec<String>)> = edited.as_ref().and_then(|e| {
|
||||
crate::forms_ui::focused_field(e, &self.session.get(id)?.form).map(|f| (crate::forms_ui::draft_text(f), f.value.clone()))
|
||||
});
|
||||
match self.session.field_focus(id, &name, widget, focus) {
|
||||
match self.session.field_focus_target(id, &name, obj.map(pdfcraft_engine::js::field_object_id), widget, focus) {
|
||||
Ok(changed) => {
|
||||
if changed
|
||||
&& let Some(info) = self.session.get(id).map(|d| d.info.clone())
|
||||
@@ -854,6 +867,42 @@ impl PdfCraftApp {
|
||||
}
|
||||
}
|
||||
|
||||
/// The pointer triggers tab `i` queued (Mouse Enter, Exit, Down, and a check box's Mouse Up
|
||||
/// after its toggle), each running its widget's JavaScript in the document's realm (#269).
|
||||
fn run_field_triggers(&mut self, i: usize) {
|
||||
let Some(view) = self.views.get_mut(i) else { return };
|
||||
let triggers = std::mem::take(&mut view.forms.triggers);
|
||||
let id = view.id;
|
||||
for ((name, object, widget), trigger) in triggers {
|
||||
// The field being edited as it was, so a script changing it shows in its editor
|
||||
// while a draft the user already typed into is kept (as `run_focus_scripts` does).
|
||||
let before: Option<(String, Vec<String>)> = self.views.get(i).and_then(|v| v.forms.focus.as_ref()).and_then(|e| {
|
||||
crate::forms_ui::focused_field(e, &self.session.get(id)?.form).map(|f| (crate::forms_ui::draft_text(f), f.value.clone()))
|
||||
});
|
||||
match self.session.field_trigger_target(id, &name, Some(object), widget, trigger) {
|
||||
Ok(changed) => {
|
||||
if changed
|
||||
&& let Some(info) = self.session.get(id).map(|d| d.info.clone())
|
||||
&& let Some(view) = self.views.get_mut(i)
|
||||
{
|
||||
view.document_changed(&info);
|
||||
if let Some(before) = &before
|
||||
&& let Some(draft) = view.forms.focus.as_mut()
|
||||
&& let Some(f) = self.session.get(id).and_then(|d| crate::forms_ui::focused_field(draft, &d.form))
|
||||
{
|
||||
crate::forms_ui::refresh_draft(draft, (&before.0, &before.1), (crate::forms_ui::draft_text(f), &f.value));
|
||||
}
|
||||
}
|
||||
let out = self.session.take_js_output(id);
|
||||
if !out.is_empty() {
|
||||
self.handle_js(id, out);
|
||||
}
|
||||
}
|
||||
Err(e) => self.notify_error(e),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Apply the edit queued on tab `i` (the active one). Returns false only when it was a
|
||||
/// field's typing and the field refused it (#166): the editor reopens with the text, and the
|
||||
/// user has been told why, so the typing isn't lost and the caller can stop.
|
||||
@@ -1578,6 +1627,43 @@ mod revert_draft_tests {
|
||||
assert_eq!(hint(&app), "", "hint's enter ran before the document is read");
|
||||
assert_eq!(app.views[0].forms.focus.as_ref().map(|f| f.text.as_str()), Some(""), "and its editor shows it");
|
||||
}
|
||||
/// A pointer trigger whose script changes the field being edited shows the change in its
|
||||
/// editor, unless the user already typed there: then the typed draft is kept.
|
||||
#[test]
|
||||
fn a_pointer_trigger_changing_the_edited_field_refreshes_an_untouched_draft() {
|
||||
let objs = [
|
||||
"<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [4 0 R 6 0 R] /DA (/Helv 0 Tf 0 g) /DR << /Font << /Helv 5 0 R >> >> >> >>".to_string(),
|
||||
"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 200 300] >>".into(),
|
||||
"<< /Type /Page /Parent 2 0 R /Annots [4 0 R 6 0 R] >>".into(),
|
||||
"<< /Type /Annot /Subtype /Widget /FT /Tx /T (name) /V (old) /Rect [20 250 180 270] /P 3 0 R /F 4 >>".into(),
|
||||
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>".into(),
|
||||
"<< /Type /Annot /Subtype /Widget /FT /Tx /T (hot) /V () /Rect [20 200 180 220] /P 3 0 R /F 4 /AA << /E << /S /JavaScript /JS (getField\\('name'\\).value = 'new';) >> >> >>".into(),
|
||||
];
|
||||
let mut pdf = b"%PDF-1.7\n".to_vec();
|
||||
let mut offsets = Vec::new();
|
||||
for (i, o) in objs.iter().enumerate() {
|
||||
offsets.push(pdf.len());
|
||||
pdf.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes());
|
||||
}
|
||||
let xref = pdf.len();
|
||||
pdf.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes());
|
||||
for o in offsets {
|
||||
pdf.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes());
|
||||
}
|
||||
pdf.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes());
|
||||
for (typed, shown) in [(false, "new"), (true, "typed")] {
|
||||
let mut app = PdfCraftApp::new();
|
||||
app.open_bytes("hot.pdf", None, pdf.clone()).unwrap();
|
||||
let hot = app.session.get(app.views[0].id).unwrap().form.iter().find(|f| f.name == "hot").unwrap().obj;
|
||||
let text = if typed { "typed" } else { "old" };
|
||||
app.views[0].forms.focus = Some(Focus { text: text.into(), picked: vec!["old".into()], ..focus("") });
|
||||
let hot = ("hot".to_string(), pdfcraft_engine::js::field_object_id(hot), 0);
|
||||
app.views[0].forms.triggers.push((hot, pdfcraft_engine::FieldTrigger::MouseEnter));
|
||||
app.run_field_triggers(0);
|
||||
assert_eq!(app.views[0].forms.focus.as_ref().map(|f| f.text.as_str()), Some(shown), "typed: {typed}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn successful_revert_clears_focused_or_queued_drafts_only_on_its_document() {
|
||||
for queued in [false, true] {
|
||||
|
||||
@@ -128,6 +128,7 @@ impl PdfCraftApp {
|
||||
if !self.flatten_fill_sign_for_save(id) {
|
||||
return false;
|
||||
}
|
||||
self.run_document_actions(id, pdfcraft_engine::js::DocumentScriptEvent::WillSave);
|
||||
let bytes = match self.session.save_bytes(id) {
|
||||
Ok(b) => b,
|
||||
Err(e) => {
|
||||
@@ -293,6 +294,7 @@ impl PdfCraftApp {
|
||||
self.notify_tr("The document was closed before it could be saved.");
|
||||
return false;
|
||||
};
|
||||
self.run_document_actions(id, pdfcraft_engine::js::DocumentScriptEvent::WillSave);
|
||||
let bytes = if full { self.session.save_full_bytes(id) } else { self.session.save_bytes(id) };
|
||||
let bytes = match bytes.and_then(|b| self.session.check_save(id, &b).map(|()| b)) {
|
||||
Ok(b) => b,
|
||||
|
||||
@@ -101,11 +101,18 @@ pub struct FormView {
|
||||
/// The field and widget that had focus when it was last looked at: when the focus moves, the
|
||||
/// one it left runs its XFA exit scripts and the one it reached its enter scripts
|
||||
/// (`PdfCraftApp::run_focus_scripts`).
|
||||
pub(crate) focused: Option<(String, usize)>,
|
||||
pub(crate) focused: Option<(String, Option<ObjRef>, usize)>,
|
||||
/// The list box row clicked last, without Shift: (its field name, the row). A Shift-click picks
|
||||
/// the rows from it to the one clicked; the field's value can't say which row came last when
|
||||
/// rows share an export value, and `/I` lists them in ascending order.
|
||||
pub(crate) list_anchor: Option<(String, usize)>,
|
||||
/// The field and widget under the pointer when it was last looked at: when it changes, the
|
||||
/// one left runs its Mouse Exit JavaScript and the one reached its Mouse Enter (#269).
|
||||
pub(crate) hovered: Option<WidgetRef>,
|
||||
/// Pointer triggers waiting to run their widget's JavaScript, in order (field name, widget,
|
||||
/// trigger): `PdfCraftApp::run_field_triggers` runs them after the frame's edit, so a check
|
||||
/// box's Mouse Up sees the value its click just stored (#247).
|
||||
pub(crate) triggers: Vec<(WidgetRef, pdfcraft_engine::FieldTrigger)>,
|
||||
}
|
||||
|
||||
/// A form message for the app to show, with document data kept separate so the visible
|
||||
@@ -458,6 +465,38 @@ pub(crate) fn hit(xf: &PageXform, info: &DocInfo, page: usize, form: &[FormField
|
||||
Some(Hit::new(Owner::Field { field, widget }, Layer::Field, icon))
|
||||
}
|
||||
|
||||
/// A widget a pointer trigger is for: its field's name and object (so a field sharing another's
|
||||
/// name runs its own script), and the widget's index in the field.
|
||||
pub(crate) type WidgetRef = (String, u64, usize);
|
||||
|
||||
/// `f`'s widget `widget` as a trigger names it.
|
||||
fn widget_ref(f: &FormField, widget: usize) -> WidgetRef {
|
||||
(f.name.clone(), pdfcraft_engine::js::field_object_id(f.obj), widget)
|
||||
}
|
||||
|
||||
/// The field and widget under the pointer on a page, when a press there would take it.
|
||||
pub(crate) fn hovered(form: &[FormField], hover: Option<&Hit>) -> Option<WidgetRef> {
|
||||
match hover.map(|h| h.owner) {
|
||||
Some(Owner::Field { field, widget }) => form.get(field).map(|f| widget_ref(f, widget)),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Queue Mouse Exit for the widget the pointer left and Mouse Enter for the one it reached, when
|
||||
/// they differ from the last frame's (`now`: the widget under the pointer on any page).
|
||||
pub(crate) fn hover_moved(view: &mut DocView, now: Option<WidgetRef>) {
|
||||
if view.forms.hovered == now {
|
||||
return;
|
||||
}
|
||||
let left = std::mem::replace(&mut view.forms.hovered, now.clone());
|
||||
if let Some(w) = left {
|
||||
view.forms.triggers.push((w, pdfcraft_engine::FieldTrigger::MouseExit));
|
||||
}
|
||||
if let Some(w) = now {
|
||||
view.forms.triggers.push((w, pdfcraft_engine::FieldTrigger::MouseEnter));
|
||||
}
|
||||
}
|
||||
|
||||
/// The tooltip (`/TU`) of the field under the pointer, as Acrobat shows it on hover (#3854): only
|
||||
/// where the field is what a press would take (`hover`, the top hit there). ISO 32000-2 Table 226
|
||||
/// makes it the name the user interface identifies the field by.
|
||||
@@ -484,6 +523,10 @@ pub(crate) fn page_input(
|
||||
let PagePress::Mine(Owner::Field { field, widget: wi }) = press else { return false };
|
||||
let Some((f, w)) = form.get(field).and_then(|f| f.widgets.get(wi).map(|w| (f, w))) else { return false };
|
||||
let usable = usable(f, w, allowed);
|
||||
// The press runs the widget's Mouse Down JavaScript, as in Acrobat (#269).
|
||||
if usable && ptr.pressed().is_some() {
|
||||
view.forms.triggers.push((widget_ref(f, wi), pdfcraft_engine::FieldTrigger::MouseDown));
|
||||
}
|
||||
// A text field opens on the press rather than the click, so a drag that starts on it
|
||||
// selects its text (#2213).
|
||||
if f.kind == FormFieldKind::Text
|
||||
@@ -544,6 +587,10 @@ pub(crate) fn page_input(
|
||||
commit(view, form);
|
||||
view.forms.focus = Some(open_focus(f, wi));
|
||||
}
|
||||
// A combo box's click runs its Mouse Up JavaScript (#269).
|
||||
if f.kind == FormFieldKind::Combo {
|
||||
view.forms.triggers.push((widget_ref(f, wi), pdfcraft_engine::FieldTrigger::MouseUp));
|
||||
}
|
||||
}
|
||||
FormFieldKind::List => {
|
||||
let Some(p) = ptr.within(xf.rect) else { return true };
|
||||
@@ -589,6 +636,8 @@ pub(crate) fn page_input(
|
||||
view.pending_edit = Some(list_pick_edit(form, f, picked));
|
||||
}
|
||||
}
|
||||
// A list box's click runs its Mouse Up JavaScript after the pick is stored (#269).
|
||||
view.forms.triggers.push((widget_ref(f, wi), pdfcraft_engine::FieldTrigger::MouseUp));
|
||||
}
|
||||
}
|
||||
true
|
||||
@@ -1376,6 +1425,9 @@ pub(crate) fn overlay(
|
||||
view.forms.toggle = true;
|
||||
} else if toggle {
|
||||
edit = toggle_edit(f, focus.widget);
|
||||
// The click's Mouse Up JavaScript runs once the toggle is stored, so it reads the
|
||||
// new value (#247).
|
||||
view.forms.triggers.push((widget_ref(f, focus.widget), pdfcraft_engine::FieldTrigger::MouseUp));
|
||||
} else if space {
|
||||
// Space toggles a check box and selects a radio button, as in browsers: it never
|
||||
// turns the selected button off.
|
||||
|
||||
@@ -200,6 +200,23 @@ impl PdfCraftApp {
|
||||
true
|
||||
}
|
||||
|
||||
/// Run document `id`'s catalog JavaScript for `event` (Will Save before a save, Will Print
|
||||
/// before printing) and show what it said. What it changed is part of what is saved or
|
||||
/// printed. A failing script is reported and the save or print still goes ahead.
|
||||
pub(crate) fn run_document_actions(&mut self, id: DocId, event: pdfcraft_engine::js::DocumentScriptEvent) {
|
||||
let before = self.session.get(id).map(|d| d.edit_generation());
|
||||
let out = self.session.run_document_actions(id, event);
|
||||
if self.session.get(id).map(|d| d.edit_generation()) != before
|
||||
&& let Some(i) = self.views.iter().position(|v| v.id == id)
|
||||
&& let Some(info) = self.session.get(id).map(|d| d.info.clone())
|
||||
{
|
||||
self.views[i].document_changed(&info);
|
||||
}
|
||||
if !out.is_empty() {
|
||||
self.handle_js(id, out);
|
||||
}
|
||||
}
|
||||
|
||||
/// Run a push button's JavaScript (its Mouse Up action).
|
||||
pub fn run_button_script(&mut self, id: DocId, field: &str, script: &str) {
|
||||
self.run_button_script_target(id, field, None, script);
|
||||
|
||||
@@ -633,6 +633,16 @@ impl PdfCraftApp {
|
||||
return false;
|
||||
}
|
||||
let Some((_, id)) = self.active_ids() else { return false };
|
||||
// A form's Will Print script runs first: what it changes is printed.
|
||||
if self.session.get(id).is_some_and(|d| d.allows_printing()) {
|
||||
let before = self.session.get(id).map(|d| d.edit_generation());
|
||||
self.run_document_actions(id, pdfcraft_engine::js::DocumentScriptEvent::WillPrint);
|
||||
// What it showed may lie outside the printed areas Label printing found: find them again.
|
||||
if self.session.get(id).map(|d| d.edit_generation()) != before && self.print_draft.handling == Handling::Label {
|
||||
self.print_draft.crop_pages = None;
|
||||
self.read_label_crops(id);
|
||||
}
|
||||
}
|
||||
let Some(doc) = self.session.get(id) else { return false };
|
||||
let labels: Vec<String> = doc.info.pages.iter().map(|p| p.label.clone()).collect();
|
||||
let settings = match self.print_draft.settings(doc.info.pages.len(), &labels) {
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
//! A form field widget's pointer scripts in the app (#269, ported from #344): the pointer
|
||||
//! moving onto and off a widget runs its Mouse Enter and Mouse Exit JavaScript, and a click on a
|
||||
//! check box runs its Mouse Up JavaScript after the click stored the new value (#247).
|
||||
|
||||
use egui_kittest::Harness;
|
||||
use pdfcraft_ui_egui::PdfCraftApp;
|
||||
|
||||
/// `log` records what ran; `hot` (a text field) logs the pointer entering and leaving it;
|
||||
/// `strike` (a check box) shows or hides `note` from its Mouse Up script by its own new value.
|
||||
fn form() -> Vec<u8> {
|
||||
let objs = [
|
||||
"<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [4 0 R 5 0 R 6 0 R 8 0 R] /DA (/Helv 0 Tf 0 g) /DR << /Font << /Helv 7 0 R >> >> >> >>",
|
||||
"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 300 400] >>",
|
||||
"<< /Type /Page /Parent 2 0 R /Annots [4 0 R 5 0 R 6 0 R 8 0 R] >>",
|
||||
"<< /Type /Annot /Subtype /Widget /FT /Tx /T (log) /V () /Rect [20 350 280 370] /P 3 0 R /F 4 >>",
|
||||
"<< /Type /Annot /Subtype /Widget /FT /Tx /T (hot) /V () /Rect [20 250 280 290] /P 3 0 R /F 4 /AA << /E << /S /JavaScript /JS (getField\\('log'\\).value += 'in;';) >> /X << /S /JavaScript /JS (getField\\('log'\\).value += 'out;';) >> >> >>",
|
||||
"<< /Type /Annot /Subtype /Widget /FT /Btn /T (strike) /V /Off /AS /Off /Rect [20 300 50 330] /P 3 0 R /F 4 /MK << /CA (4) >> /AP << /N << /Yes 9 0 R /Off 9 0 R >> >> /A << /S /JavaScript /JS (getField\\('note'\\).display = event.target.value == 'Off' ? display.hidden : display.visible; getField\\('log'\\).value += 'up:' + event.target.value + ';';) >> >>",
|
||||
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>",
|
||||
"<< /Type /Annot /Subtype /Widget /FT /Tx /T (note) /V (struck) /Rect [60 300 280 330] /P 3 0 R /F 6 >>",
|
||||
"<< /Type /XObject /Subtype /Form /BBox [0 0 30 30] /Length 0 >>\nstream\n\nendstream",
|
||||
];
|
||||
let mut out = b"%PDF-1.7\n".to_vec();
|
||||
let mut offsets = Vec::new();
|
||||
for (i, o) in objs.iter().enumerate() {
|
||||
offsets.push(out.len());
|
||||
out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes());
|
||||
}
|
||||
let xref = out.len();
|
||||
out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes());
|
||||
for o in offsets {
|
||||
out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes());
|
||||
}
|
||||
out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes());
|
||||
out
|
||||
}
|
||||
|
||||
fn harness() -> Harness<'static, PdfCraftApp> {
|
||||
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(|_cc| {
|
||||
let mut app = PdfCraftApp::new();
|
||||
app.set_option("language", "en").unwrap();
|
||||
app.set_option("left", "closed").unwrap();
|
||||
app.open_bytes("triggers.pdf", None, form()).unwrap();
|
||||
app
|
||||
});
|
||||
crate::support::settle(&mut h);
|
||||
h
|
||||
}
|
||||
|
||||
fn field<'a>(app: &'a PdfCraftApp, name: &str) -> &'a pdfcraft_engine::FormField {
|
||||
let id = app.active_ids().unwrap().1;
|
||||
app.session.get(id).unwrap().form.iter().find(|f| f.name == name).unwrap()
|
||||
}
|
||||
|
||||
fn centre(h: &Harness<'static, PdfCraftApp>, name: &str) -> egui::Pos2 {
|
||||
let app = h.state();
|
||||
let (index, id) = app.active_ids().unwrap();
|
||||
let doc = app.session.get(id).unwrap();
|
||||
pdfcraft_ui_egui::forms_ui::field_screen_rect(&app.views[index], &doc.info, field(app, name), 0).unwrap().center()
|
||||
}
|
||||
|
||||
fn log(h: &Harness<'static, PdfCraftApp>) -> String {
|
||||
field(h.state(), "log").value.join(",")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_pointer_entering_and_leaving_a_widget_runs_its_scripts() {
|
||||
let mut h = harness();
|
||||
let hot = centre(&h, "hot");
|
||||
let away = hot + egui::vec2(0.0, 200.0);
|
||||
h.hover_at(away);
|
||||
h.run_steps(2);
|
||||
h.hover_at(hot);
|
||||
crate::support::wait_until(&mut h, "Mouse Enter ran", |app| field(app, "log").value.join(",") == "in;");
|
||||
// Moving within the widget runs nothing more.
|
||||
h.hover_at(hot + egui::vec2(10.0, 0.0));
|
||||
h.run_steps(3);
|
||||
assert_eq!(log(&h), "in;");
|
||||
h.hover_at(away);
|
||||
crate::support::wait_until(&mut h, "Mouse Exit ran", |app| field(app, "log").value.join(",") == "in;out;");
|
||||
}
|
||||
|
||||
/// The check box's Mouse Up reads the value its click stored: checking it shows the note,
|
||||
/// unchecking hides it again (#247).
|
||||
#[test]
|
||||
fn a_check_box_mouse_up_script_sees_the_value_its_click_stored() {
|
||||
let mut h = harness();
|
||||
let strike = centre(&h, "strike");
|
||||
let click = |h: &mut Harness<'static, PdfCraftApp>| {
|
||||
h.hover_at(strike);
|
||||
h.run_steps(1);
|
||||
h.drag_at(strike);
|
||||
h.run_steps(1);
|
||||
h.drop_at(strike);
|
||||
h.run_steps(1);
|
||||
};
|
||||
click(&mut h);
|
||||
crate::support::wait_until(&mut h, "checked and its Mouse Up ran", |app| field(app, "log").value.join(",") == "up:Yes;");
|
||||
assert_eq!(field(h.state(), "strike").value, ["Yes"]);
|
||||
assert!(!field(h.state(), "note").widgets[0].hidden, "shown by the script");
|
||||
h.run_steps(6);
|
||||
click(&mut h);
|
||||
crate::support::wait_until(&mut h, "unchecked and its Mouse Up ran", |app| field(app, "log").value.join(",") == "up:Yes;up:Off;");
|
||||
assert!(field(h.state(), "note").widgets[0].hidden, "hidden again");
|
||||
}
|
||||
@@ -43,6 +43,7 @@ mod download_mime;
|
||||
mod editing;
|
||||
mod editor_taps;
|
||||
mod export;
|
||||
mod field_triggers;
|
||||
mod fill_sign;
|
||||
mod find_replace;
|
||||
mod folders;
|
||||
|
||||
@@ -115,9 +115,19 @@ supply an anchored renderer through the UI menu callback when it adds link actio
|
||||
the shared parser lands upstream, this integration can use it directly.
|
||||
|
||||
`ScriptSession::run_host_task` gives future timer and page actions (#4751) access to the
|
||||
retained realm on its own worker. `DocumentScriptEvent` and `run_document_script_event`
|
||||
provide explicit entry points for catalog OpenAction, WillSave and WillPrint (#344).
|
||||
The callers for those document actions are part of #344.
|
||||
retained realm on its own worker.
|
||||
|
||||
Document actions and widget triggers (ported from #344 by @DWAA1660) run through the same
|
||||
realm. On opening, the document scripts run, then the catalog `/OpenAction`'s JavaScript
|
||||
and its `/Next` chain (event `Doc/Open`); Revert runs them again in a fresh realm.
|
||||
`Session::run_document_actions` runs `/AA /WS` before a save and `/AA /WP` before printing:
|
||||
the app's Save and Print and the `doc_save` and `doc_print` tools call it first. A failing
|
||||
action is reported and never stops the save or print. `Session::field_trigger` runs a
|
||||
widget's Mouse Enter, Mouse Exit, Mouse Down, Mouse Up, Focus or Blur JavaScript; the app
|
||||
queues them from the pointer and the focus, and `js_event` runs them headlessly. A check
|
||||
box's or radio button's Mouse Up runs after its click has stored the new value (#247).
|
||||
Scripts that run unasked (open, Will Save, Will Print, pointer over, focus) may only ask for
|
||||
highlighting, zoom, focus or a page.
|
||||
|
||||
The lexical preflight recognizes unambiguous top-level and block-body ASI boundaries before identifiers, including declarations and assignments separated by line breaks or comments. It preserves counters after operators and member access, and across binary keyword operators. It distinguishes control-statement parentheses for regexp literals; inside expression parentheses, array/object literals and template interpolations it remains conservative; this is not a complete ECMAScript parser or ASI implementation.
|
||||
|
||||
|
||||
@@ -5348,11 +5348,11 @@ title = "Document actions (will close, will save, did save, will print, did prin
|
||||
area = "F"
|
||||
tier = "P1"
|
||||
milestone = "M6"
|
||||
status = "planned"
|
||||
commands = []
|
||||
tools = []
|
||||
evidence = []
|
||||
notes = ""
|
||||
status = "partial"
|
||||
commands = ["file.save", "print.dialog"]
|
||||
tools = ["doc_open", "doc_save", "doc_print"]
|
||||
evidence = ["crates/engine/src/document_actions_tests.rs::the_open_action_runs_once_on_opening_in_the_document_realm", "crates/engine/src/document_actions_tests.rs::an_open_action_chain_runs_in_order_and_a_cycle_runs_each_action_once", "crates/engine/src/document_actions_tests.rs::an_open_action_may_not_open_a_url_or_print", "crates/engine/src/document_actions_tests.rs::the_open_action_does_not_run_with_javascript_off", "crates/engine/src/document_actions_tests.rs::revert_runs_the_open_action_again_in_a_fresh_realm", "crates/engine/src/document_actions_tests.rs::will_save_changes_what_is_saved_and_a_failing_one_does_not_stop_the_save", "crates/engine/src/document_actions_tests.rs::will_print_runs_in_the_document_realm", "crates/automation/tests/it/document_actions.rs::open_will_save_will_print_and_field_events_share_the_document_realm"]
|
||||
notes = "The catalog /OpenAction's JavaScript (with its /Next chain, bounded and cycle-safe) runs once on opening after the document scripts, and again on Revert in a fresh realm; /AA /WS runs before a save (the app's Save and Save As, doc_save) and /AA /WP before printing (the app's Print, doc_print), in the document's realm, so what they change is saved or printed. A failing action is reported and never stops the save or print. These scripts run unasked, so they may only ask for highlighting, zoom, focus or a page. Missing: Will Close (/WC), Did Save (/DS), Did Print (/DP) and page open/close actions (#269, ported from #344)."
|
||||
|
||||
[[feature]]
|
||||
id = "form.js-host"
|
||||
@@ -5362,9 +5362,9 @@ tier = "P0"
|
||||
milestone = "M6"
|
||||
status = "partial"
|
||||
commands = ["tools.js_console"]
|
||||
tools = ["js_run"]
|
||||
evidence = ["crates/automation/tests/it/timesheet_compat.rs::timesheet_keypad_geometry_icons_calculation_and_persistence", "crates/automation/tests/it/timesheet_compat.rs::popup_and_manual_response_resume_the_same_script_and_validate_choices", "crates/js/src/session.rs::document_realm_keeps_keypad_state_and_isolates_documents", "crates/js/src/tests.rs::calculate_scripts_read_fields_and_set_the_value", "crates/engine/src/tests.rs::form_javascript_validates_calculates_and_formats", "crates/automation/tests/it/automation.rs::javascript_through_tools", "crates/js/src/tests.rs::an_xfa_form_sees_the_xfa_plugin_installed", "crates/js/src/tests.rs::a_plain_form_sees_the_xfa_version_but_not_installed", "crates/engine/src/tests.rs::scripts_in_an_xfa_form_see_the_xfa_plugin_installed", "crates/js/src/tests.rs::scand_allows_separators_between_fields_but_nothing_before_or_after_them", "crates/js/src/tests.rs::app_alert_keeps_its_title_icon_and_buttons", "crates/js/src/tests.rs::app_alert_reads_the_object_form", "crates/js/src/tests.rs::app_alert_falls_back_on_bad_icon_and_button_numbers", "crates/js/src/tests.rs::app_alert_titles_are_clipped_and_charged", "crates/ui-egui/tests/it/javascript.rs::every_message_a_script_shows_takes_the_toast_in_turn", "crates/ui-egui/tests/it/forms.rs::a_form_button_opens_its_attached_instructions_in_a_new_tab", "crates/js/src/tests.rs::widget_rect_reads_and_writes_a_four_number_array", "crates/engine/src/tests.rs::javascript_field_rect_moves_first_widget_and_undoes"]
|
||||
notes = "Document-scoped native Boa realms shared by button, console and field events; synchronous calculateNow in CO order; normal captions/icons; rect and appearance/tooltip writeback; effective page boxes; native explicit-button popup/response/alert continuation and viewer requests, including opening or saving attachments from form buttons. Headless js_run has deterministic ordered answers. Partial: web prompt UI, timers, annotation/layer APIs, non-normal button faces, widget-indexed Field access and remaining document action triggers are not implemented. See docs/acroform-script-workflows.md for lifecycle, limits and validation scope."
|
||||
tools = ["js_run", "js_event"]
|
||||
evidence = ["crates/js/src/tests.rs::to_source_writes_plain_state_as_a_literal", "crates/js/src/tests.rs::to_source_refuses_cycles_functions_and_oversized_state", "crates/js/src/tests.rs::set_items_and_set_action_change_the_field", "crates/engine/src/document_actions_tests.rs::set_items_and_set_action_are_written_to_the_document", "crates/engine/src/document_actions_tests.rs::pointer_and_focus_triggers_run_the_widget_scripts", "crates/engine/src/document_actions_tests.rs::only_a_click_trigger_may_print", "crates/engine/src/document_actions_tests.rs::a_check_box_mouse_up_reads_the_value_its_click_stored", "crates/ui-egui/tests/it/field_triggers.rs::the_pointer_entering_and_leaving_a_widget_runs_its_scripts", "crates/ui-egui/tests/it/field_triggers.rs::a_check_box_mouse_up_script_sees_the_value_its_click_stored", "crates/automation/tests/it/timesheet_compat.rs::timesheet_keypad_geometry_icons_calculation_and_persistence", "crates/automation/tests/it/timesheet_compat.rs::popup_and_manual_response_resume_the_same_script_and_validate_choices", "crates/js/src/session.rs::document_realm_keeps_keypad_state_and_isolates_documents", "crates/js/src/tests.rs::calculate_scripts_read_fields_and_set_the_value", "crates/engine/src/tests.rs::form_javascript_validates_calculates_and_formats", "crates/automation/tests/it/automation.rs::javascript_through_tools", "crates/js/src/tests.rs::an_xfa_form_sees_the_xfa_plugin_installed", "crates/js/src/tests.rs::a_plain_form_sees_the_xfa_version_but_not_installed", "crates/engine/src/tests.rs::scripts_in_an_xfa_form_see_the_xfa_plugin_installed", "crates/js/src/tests.rs::scand_allows_separators_between_fields_but_nothing_before_or_after_them", "crates/js/src/tests.rs::app_alert_keeps_its_title_icon_and_buttons", "crates/js/src/tests.rs::app_alert_reads_the_object_form", "crates/js/src/tests.rs::app_alert_falls_back_on_bad_icon_and_button_numbers", "crates/js/src/tests.rs::app_alert_titles_are_clipped_and_charged", "crates/ui-egui/tests/it/javascript.rs::every_message_a_script_shows_takes_the_toast_in_turn", "crates/ui-egui/tests/it/forms.rs::a_form_button_opens_its_attached_instructions_in_a_new_tab", "crates/js/src/tests.rs::widget_rect_reads_and_writes_a_four_number_array", "crates/engine/src/tests.rs::javascript_field_rect_moves_first_widget_and_undoes"]
|
||||
notes = "Document-scoped native Boa realms shared by button, console and field events; synchronous calculateNow in CO order; normal captions/icons; rect and appearance/tooltip writeback; effective page boxes; native explicit-button popup/response/alert continuation and viewer requests, including opening or saving attachments from form buttons. Headless js_run has deterministic ordered answers. A widget's Mouse Enter, Mouse Exit, Mouse Down, Focus and Blur JavaScript run in the app and through js_event (and form_focus for focus), and a check box's or radio button's Mouse Up runs after its click stored the new value (#247); field.setItems, field.setAction and a bounded Object.prototype.toSource (plain data only) are written back to the document (#269, ported from #344). Partial: web prompt UI, timers, annotation/layer APIs, non-normal button faces, widget-indexed Field access, and Will Close / Did Save / Did Print are not implemented. See docs/acroform-script-workflows.md for lifecycle, limits and validation scope."
|
||||
|
||||
[[feature]]
|
||||
id = "form.js-console"
|
||||
|
||||
Reference in new issue
Block a user