mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-11-14 06:42:20 +00:00
js: app.openDoc request, a link event's document target, empty popUpMenuEx
Ported from #2081: app.openDoc only queues Request::OpenDoc for the current document's folder (the viewer asks first) and answers null; a Link event's target is the document; a popup menu with no items answers null without asking. Co-authored-by: LMatt08 <245691047+LMatt08@users.noreply.github.com>
This commit is contained in:
1 parent
6d03a7acdf
commit
d9c895a32f
5 files changed
+136
-14
No files matched your search
@@ -140,6 +140,41 @@ fn launch_url(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult<JsVa
|
||||
Ok(JsValue::undefined())
|
||||
}
|
||||
|
||||
/// `app.openDoc(cPath, oDoc)` or `app.openDoc({cPath, oDoc, cFS})`: queues a request to open
|
||||
/// the local file `cPath`, relative to this document. Only this document may be the base and
|
||||
/// only the local file system is supported; other options are refused. The viewer asks the
|
||||
/// user first, so no document object can be returned: the call answers null (#2081).
|
||||
fn open_doc(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult<JsValue> {
|
||||
let unset = |v: &JsValue| v.is_undefined() || v.is_null();
|
||||
// Only this context's own document object (not a lookalike, proxy or inherited object).
|
||||
let this_document = |v: &JsValue, ctx: &Context| unset(v) || v.as_object().is_some_and(|o| JsObject::equals(&o, &ctx.global_object()));
|
||||
let first = arg(args, 0);
|
||||
let path = match first.as_object().filter(|o| !o.is_array() && !o.is_callable()) {
|
||||
Some(o) => {
|
||||
if !args.iter().skip(1).all(unset) {
|
||||
return Err(error("unsupported openDoc options"));
|
||||
}
|
||||
let fs = o.get(js_string!("cFS"), ctx)?;
|
||||
let base = o.get(js_string!("oDoc"), ctx)?;
|
||||
if (!fs.is_undefined() && !text(&fs, ctx)?.is_empty()) || !this_document(&base, ctx) {
|
||||
return Err(error("openDoc supports only the current document's local file system"));
|
||||
}
|
||||
text(&o.get(js_string!("cPath"), ctx)?, ctx)?
|
||||
}
|
||||
None => {
|
||||
if !this_document(&arg(args, 1), ctx) || !args.iter().skip(2).all(unset) {
|
||||
return Err(error("unsupported openDoc options"));
|
||||
}
|
||||
text(&first, ctx)?
|
||||
}
|
||||
};
|
||||
if path.is_empty() || path.len() > 4096 {
|
||||
return Err(error("invalid openDoc path"));
|
||||
}
|
||||
host(ctx)?.borrow_mut().request(Request::OpenDoc(path));
|
||||
Ok(JsValue::null())
|
||||
}
|
||||
|
||||
/// The attached file `exportDataObject` names, and whether to open it: from an object
|
||||
/// (`{cName, nLaunch}`) or from the arguments (`cName, cDIPath, bAllowAuth, nLaunch`). `nLaunch`
|
||||
/// 1 and 2 open the file; 0 (the default) and any other value save it.
|
||||
@@ -555,6 +590,7 @@ pub(crate) fn install(ctx: &mut Context) -> JsResult<()> {
|
||||
.function(NativeFunction::from_fn_ptr(crate::interaction::response), js_string!("response"), 1)
|
||||
.function(NativeFunction::from_fn_ptr(launch_url), js_string!("launchURL"), 2)
|
||||
.function(NativeFunction::from_fn_ptr(crate::interaction::popup_menu), js_string!("popUpMenuEx"), 0)
|
||||
.function(NativeFunction::from_fn_ptr(open_doc), js_string!("openDoc"), 1)
|
||||
.function(NativeFunction::from_fn_ptr(exec_menu_item), js_string!("execMenuItem"), 1)
|
||||
.function(NativeFunction::from_fn_ptr(timer), js_string!("setTimeOut"), 2)
|
||||
.function(NativeFunction::from_fn_ptr(timer), js_string!("setInterval"), 2)
|
||||
@@ -622,13 +658,16 @@ pub(crate) fn event_object(ctx: &mut Context, e: &Event) -> JsResult<(JsObject,
|
||||
.build();
|
||||
let value_get = function(ctx, NativeFunction::from_copy_closure_with_captures(get, store.clone()));
|
||||
let value_set = function(ctx, NativeFunction::from_copy_closure_with_captures(set, store.clone()));
|
||||
let target = e.target.as_ref().map_or(JsValue::null(), |t| {
|
||||
if e.target_id.is_some() {
|
||||
crate::document::field_object_by_id(ctx, t, e.target_id).into()
|
||||
} else {
|
||||
crate::document::field_object(ctx, t).into()
|
||||
}
|
||||
});
|
||||
// A link action's target is the document (#2081); other events without a field have none.
|
||||
let link_document = e.target.is_none() && e.kind == "Link";
|
||||
let target = match e.target.as_ref() {
|
||||
Some(t) if e.target_id.is_some() => crate::document::field_object_by_id(ctx, t, e.target_id).into(),
|
||||
Some(t) => crate::document::field_object(ctx, t).into(),
|
||||
None if link_document => ctx.global_object().into(),
|
||||
None => JsValue::null(),
|
||||
};
|
||||
// Not enumerable there, so `JSON.stringify(event)` doesn't walk back into the document.
|
||||
let target_attributes = if link_document { Attribute::WRITABLE | Attribute::CONFIGURABLE } else { Attribute::all() };
|
||||
let event = ObjectInitializer::new(ctx)
|
||||
.property(js_string!("name"), s(&e.name), Attribute::all())
|
||||
.property(js_string!("type"), s(&e.kind), Attribute::all())
|
||||
@@ -637,7 +676,7 @@ pub(crate) fn event_object(ctx: &mut Context, e: &Event) -> JsResult<(JsObject,
|
||||
.property(js_string!("changeEx"), s(&e.change), Attribute::all())
|
||||
.property(js_string!("willCommit"), e.will_commit, Attribute::all())
|
||||
.property(js_string!("rc"), true, Attribute::all())
|
||||
.property(js_string!("target"), target, Attribute::all())
|
||||
.property(js_string!("target"), target, target_attributes)
|
||||
.property(js_string!("targetName"), s(e.target.as_deref().unwrap_or("")), Attribute::all())
|
||||
.property(js_string!("selStart"), 0, Attribute::all())
|
||||
.property(js_string!("selEnd"), 0, Attribute::all())
|
||||
|
||||
@@ -76,12 +76,13 @@ impl Host {
|
||||
Request::LaunchUrl(u) => Request::LaunchUrl(xfa::clip(u)),
|
||||
Request::Submit(u) => Request::Submit(xfa::clip(u)),
|
||||
Request::Focus(n) => Request::Focus(xfa::clip(n)),
|
||||
Request::OpenDoc(p) => Request::OpenDoc(xfa::clip(p)),
|
||||
Request::FocusField { name, object_id } => Request::FocusField { name: xfa::clip(name), object_id },
|
||||
other => other,
|
||||
};
|
||||
let bytes = match &r {
|
||||
Request::Reset(names) => names.iter().fold(0usize, |n, s| n.saturating_add(s.len()).saturating_add(std::mem::size_of::<String>())),
|
||||
Request::LaunchUrl(u) | Request::Submit(u) | Request::Focus(u) => u.len(),
|
||||
Request::LaunchUrl(u) | Request::Submit(u) | Request::Focus(u) | Request::OpenDoc(u) => u.len(),
|
||||
Request::FocusField { name, .. } => name.len(),
|
||||
Request::OpenAttachment { name, .. } => name.len(),
|
||||
_ => 0,
|
||||
|
||||
@@ -31,9 +31,9 @@ pub(crate) fn has_host() -> bool {
|
||||
pub(crate) fn set_host(host: Option<Sender<InteractiveEvent>>) {
|
||||
HOST.with(|h| *h.borrow_mut() = host.map(|h| (h, 0)));
|
||||
}
|
||||
/// Send a bounded prompt and await its reply, treating disconnect/timeout as cancellation.
|
||||
pub(crate) fn ask(prompt: Prompt) -> JsResult<Option<String>> {
|
||||
let sender = HOST.with(|h| {
|
||||
/// Charge one prompt to the invocation's budget: the channel to send it on, if the worker has one.
|
||||
fn reserve() -> JsResult<Option<Sender<InteractiveEvent>>> {
|
||||
HOST.with(|h| {
|
||||
let mut h = h.borrow_mut();
|
||||
let Some((tx, n)) = h.as_mut() else { return Ok(None) };
|
||||
if *n >= 32 {
|
||||
@@ -41,8 +41,11 @@ pub(crate) fn ask(prompt: Prompt) -> JsResult<Option<String>> {
|
||||
}
|
||||
*n += 1;
|
||||
Ok(Some(tx.clone()))
|
||||
})?;
|
||||
let Some(sender) = sender else { return Ok(None) };
|
||||
})
|
||||
}
|
||||
/// Send a bounded prompt and await its reply, treating disconnect/timeout as cancellation.
|
||||
pub(crate) fn ask(prompt: Prompt) -> JsResult<Option<String>> {
|
||||
let Some(sender) = reserve()? else { return Ok(None) };
|
||||
let (tx, rx) = mpsc::channel();
|
||||
sender.send(InteractiveEvent::Prompt(prompt, tx)).map_err(|_| error("interactive host closed"))?;
|
||||
let answer = rx.recv_timeout(std::time::Duration::from_secs(300)).ok().flatten();
|
||||
@@ -102,6 +105,12 @@ pub(crate) fn popup_menu(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> Js
|
||||
for a in args {
|
||||
items.push(read_menu(a, ctx, 0, &mut remaining)?);
|
||||
}
|
||||
// A menu with no items has nothing to pick: it answers null at once, without showing an empty
|
||||
// menu or using up a later menu's answer, but still counts toward the prompt limit (#2081).
|
||||
if items.is_empty() {
|
||||
reserve()?;
|
||||
return Ok(JsValue::null());
|
||||
}
|
||||
match ask(Prompt::Menu(items.clone()))? {
|
||||
Some(answer) if allowed(&items, &answer) => Ok(s(&answer)),
|
||||
Some(_) => Err(error("invalid popup menu selection")),
|
||||
|
||||
@@ -311,6 +311,9 @@ pub enum Request {
|
||||
Highlight(bool),
|
||||
/// `app.execMenuItem("SaveAs")` (XFA forms' Save buttons).
|
||||
SaveAs,
|
||||
/// `app.openDoc(cPath)`: the path as the script gave it (relative to this document's
|
||||
/// folder). A request only: the viewer asks the user before opening any file.
|
||||
OpenDoc(String),
|
||||
/// `this.exportDataObject(cName, …, nLaunch)`: the attached file `name`, to open (`nLaunch`
|
||||
/// 1 or 2) or to save (0).
|
||||
OpenAttachment {
|
||||
|
||||
@@ -2702,3 +2702,73 @@ 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")));
|
||||
}
|
||||
|
||||
/// A link action's `event.target` is the document (#2081, ported), and the event can still be
|
||||
/// serialized: the target isn't enumerable, so `JSON.stringify` doesn't walk back into it.
|
||||
#[test]
|
||||
fn a_link_events_target_is_the_document() {
|
||||
let link = Event { kind: "Link".into(), ..Event::doc("Mouse Up") };
|
||||
let o = go("JSON.stringify(event); JSON.stringify(this); (event.target === this) + ':' + event.type + ':' + event.name", &link);
|
||||
assert_eq!(o.error, None);
|
||||
assert_eq!(o.result.as_deref(), Some("true:Link:Mouse Up"));
|
||||
assert_eq!(go("event.target === null", &Event::doc("Console")).result.as_deref(), Some("true"), "other document events keep none");
|
||||
}
|
||||
|
||||
/// `app.openDoc` only queues a request (the viewer asks before opening a file) and answers null;
|
||||
/// another document as the base, another file system or extra options are refused (#2081).
|
||||
#[test]
|
||||
fn open_doc_queues_a_request_for_the_current_documents_folder_only() {
|
||||
let link = Event { kind: "Link".into(), ..Event::doc("Mouse Up") };
|
||||
for call in [
|
||||
"app.openDoc({cPath:'other.pdf',oDoc:this})",
|
||||
"app.openDoc('other.pdf',this)",
|
||||
"app.openDoc('other.pdf',event.target)",
|
||||
"app.openDoc({cPath:'other.pdf',oDoc:this,cFS:''})",
|
||||
"app.openDoc('other.pdf',null)",
|
||||
"app.openDoc({cPath:'other.pdf'},null,undefined)",
|
||||
] {
|
||||
let o = go(&format!("getField('price').value='17'; {call} === null"), &link);
|
||||
assert_eq!(o.error, None, "{call}");
|
||||
assert_eq!(o.result.as_deref(), Some("true"), "{call}");
|
||||
assert_eq!(o.requests, [Request::OpenDoc("other.pdf".into())], "{call}");
|
||||
}
|
||||
for call in [
|
||||
"app.openDoc({cPath:'other.pdf',oDoc:{}})",
|
||||
"app.openDoc('other.pdf',{})",
|
||||
"app.openDoc('other.pdf',Object.create(this))",
|
||||
"app.openDoc('other.pdf',new Proxy(this,{}))",
|
||||
"app.openDoc('other.pdf',getField('price'))",
|
||||
"app.openDoc('other.pdf',this,true)",
|
||||
"app.openDoc({cPath:'other.pdf',oDoc:this,cFS:'CHTTP'})",
|
||||
"app.openDoc({cPath:'other.pdf'},this)",
|
||||
"app.openDoc('')",
|
||||
] {
|
||||
let o = go(call, &link);
|
||||
assert!(o.error.as_deref().is_some_and(|e| e.contains("openDoc")), "{call}: {:?}", o.error);
|
||||
assert!(o.requests.is_empty(), "{call}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A popup menu with no items answers null without asking, so the next menu still gets its own
|
||||
/// answer, and empty menus count toward the prompt limit (#2081).
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
#[test]
|
||||
fn an_empty_popup_menu_answers_null_without_asking() {
|
||||
let job = |script: &str| crate::session::Job {
|
||||
script: script.into(),
|
||||
event: Event { kind: "Link".into(), ..Event::doc("Mouse Up") },
|
||||
doc: doc(),
|
||||
fields: Arc::new(Vec::new()),
|
||||
doc_scripts: Vec::new(),
|
||||
calculations: Vec::new(),
|
||||
};
|
||||
let mut s = crate::session::ScriptSession::default();
|
||||
let o = s.run(
|
||||
job("var a = app.popUpMenuEx(); var b = app.popUpMenuEx({cName:'B', cReturn:'b'}); app.popUpMenuEx.apply(app, []); String(a) + ':' + b"),
|
||||
&[Some("b".into())],
|
||||
);
|
||||
assert_eq!(o.error, None);
|
||||
assert_eq!(o.result.as_deref(), Some("null:b"));
|
||||
let o = s.run(job("for (var i = 0; i < 33; i++) app.popUpMenuEx();"), &[]);
|
||||
assert!(o.error.as_deref().is_some_and(|e| e.contains("too many interactive prompts")), "{:?}", o.error);
|
||||
}
|
||||
Reference in new issue
Block a user