mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-11-14 06:42:20 +00:00
Merge pull request #7144 from FredApps/port-2081
Link JavaScript actions and app.openDoc (port of #2081)
This commit is contained in:
25 files changed
+1197
-43
No files matched your search
@@ -16,11 +16,28 @@ fn request_json(r: &pdfcraft_engine::js::Request) -> Value {
|
||||
R::Beep => json!({ "beep": true }),
|
||||
R::Highlight(on) => json!({ "highlight": on }),
|
||||
R::SaveAs => json!({ "save_as": true }),
|
||||
R::OpenDoc(path) => json!({ "open_doc": path }),
|
||||
R::OpenAttachment { name, open } => json!({ "attachment": name, "open": open }),
|
||||
R::ShowPanel(p) => json!({ "panel": p.id() }),
|
||||
}
|
||||
}
|
||||
|
||||
/// The optional `answers` argument: the prompts' answers in order (null cancels one).
|
||||
fn answers(a: &Args) -> Result<Vec<Option<String>>> {
|
||||
match a.0.get("answers") {
|
||||
None => Ok(Vec::new()),
|
||||
Some(Value::Array(values)) if values.len() <= 32 => values
|
||||
.iter()
|
||||
.map(|v| match v {
|
||||
Value::Null => Ok(None),
|
||||
Value::String(s) if s.len() <= 4096 => Ok(Some(s.clone())),
|
||||
_ => Err(ToolError::InvalidArgs("answers must contain strings or null".into())),
|
||||
})
|
||||
.collect(),
|
||||
_ => Err(ToolError::InvalidArgs("answers must be an array of at most 32 strings or null".into())),
|
||||
}
|
||||
}
|
||||
|
||||
/// 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>> {
|
||||
@@ -55,18 +72,7 @@ impl Automation {
|
||||
/// messages, console lines, requests, error, the failing document scripts' errors and result.
|
||||
pub(crate) fn js_run(&mut self, a: &Args) -> Result<Value> {
|
||||
let id = self.doc(a)?.id;
|
||||
let answers = match a.0.get("answers") {
|
||||
None => Vec::new(),
|
||||
Some(Value::Array(values)) if values.len() <= 32 => values
|
||||
.iter()
|
||||
.map(|v| match v {
|
||||
Value::Null => Ok(None),
|
||||
Value::String(s) if s.len() <= 4096 => Ok(Some(s.clone())),
|
||||
_ => Err(ToolError::InvalidArgs("answers must contain strings or null".into())),
|
||||
})
|
||||
.collect::<Result<Vec<_>>>()?,
|
||||
_ => return Err(ToolError::InvalidArgs("answers must be an array of at most 32 strings or null".into())),
|
||||
};
|
||||
let answers = answers(a)?;
|
||||
let identity = field_id(a)?;
|
||||
let (script, field) = (a.str("script")?, a.opt_str("field")?);
|
||||
let (mut o, edit) =
|
||||
@@ -82,6 +88,28 @@ impl Automation {
|
||||
))
|
||||
}
|
||||
|
||||
/// `link_run_javascript`: run the JavaScript action of link `index` on page `page` (both
|
||||
/// 1-based, as link_list gives them), answering its prompts from `answers`, and report what
|
||||
/// it showed and asked for. A failed script commits nothing (#2081).
|
||||
pub(crate) fn link_run_javascript(&mut self, a: &Args) -> Result<Value> {
|
||||
let answers = answers(a)?;
|
||||
let one_based = |key: &str| -> Result<usize> {
|
||||
let n = a.int(key)?;
|
||||
usize::try_from(n).ok().and_then(|n| n.checked_sub(1)).ok_or_else(|| ToolError::InvalidArgs(format!("{key} must be 1 or more")))
|
||||
};
|
||||
let (page, index) = (one_based("page")?, one_based("index")?);
|
||||
let doc = self.doc(a)?;
|
||||
let id = doc.id;
|
||||
if !doc.info.links.iter().any(|l| l.page == page && l.index == index && matches!(l.target, pdfcraft_render::LinkTarget::JavaScript(_))) {
|
||||
return Err(failed(format!("page {} has no JavaScript link {} (see link_list)", page + 1, index + 1)));
|
||||
}
|
||||
let (o, edit) = self.session.with_own_js_output(id, |s| s.run_link_javascript(id, page, index, &answers)).map_err(failed)?;
|
||||
let reqs: Vec<Value> = o.requests.iter().chain(&edit.requests).map(request_json).collect();
|
||||
let alerts: Vec<Value> = o.alerts.iter().chain(&edit.alerts).map(alert_json).collect();
|
||||
let console: Vec<&String> = o.console.iter().chain(&edit.console).collect();
|
||||
Ok(with_warnings(json!({ "alerts": alerts, "console": console, "requests": reqs, "error": o.error, "result": o.result }), edit.errors))
|
||||
}
|
||||
|
||||
/// `js_page`: read the document at a page, running page actions as the viewer does (#169).
|
||||
pub(crate) fn js_page(&mut self, a: &Args) -> Result<Value> {
|
||||
let id = self.doc(a)?.id;
|
||||
|
||||
@@ -734,6 +734,7 @@ impl Automation {
|
||||
"link_add" => self.link_add(&a)?,
|
||||
"link_edit" => self.link_edit(&a)?,
|
||||
"link_delete" => self.link_delete(&a)?,
|
||||
"link_run_javascript" => self.link_run_javascript(&a)?,
|
||||
"links_from_urls" => self.links_from_urls(&a)?,
|
||||
"links_remove" => self.links_remove(&a)?,
|
||||
"object_list" => self.object_list(&a)?,
|
||||
|
||||
@@ -411,6 +411,7 @@ fn link_target(i: &pdfcraft_render::DocInfo, target: &pdfcraft_render::LinkTarge
|
||||
})).collect::<Vec<_>>(),
|
||||
"preserve_rb": preserve_rb,
|
||||
}),
|
||||
pdfcraft_render::LinkTarget::JavaScript(script) => json!({ "javascript": script }),
|
||||
pdfcraft_render::LinkTarget::RemoteGoTo { launch, file, destination, new_window } => json!({
|
||||
"action": if *launch { "Launch" } else { "GoToR" },
|
||||
"remote_document": file,
|
||||
|
||||
@@ -908,6 +908,9 @@ pub fn tools() -> Vec<ToolDef> {
|
||||
}),
|
||||
&["doc", "page", "index"],
|
||||
)),
|
||||
t("link_run_javascript", "Run a link's JavaScript", "Run the JavaScript action of link `index` on page `page` (1-based, from link_list: index is the link's place among the page's annotations), as clicking it does: event.type Link, event.name Mouse Up, event.target the document, this.pageNum the link's page, in the document's script realm. answers reply to its popup menus, response dialogs and alerts in order (null cancels a menu or response; missing answers do too); a menu with no items answers null without using one. Field changes and resetForm are one undoable step; a script that fails commits nothing and its requests are dropped. Returns alerts, console, requests (page, url, open_doc and others, never carried out), error and result.")
|
||||
.destructive()
|
||||
.with(schema(json!({ "doc": doc(), "page": { "type": "integer", "minimum": 1 }, "index": { "type": "integer", "minimum": 1 }, "answers": {"type":"array","maxItems":32,"items":{"type":["string","null"],"maxLength":4096}} }), &["doc", "page", "index"])),
|
||||
t("link_delete", "Delete a link", "Delete one link (page, index from link_list). Undoable.")
|
||||
.destructive()
|
||||
.with(schema(json!({ "doc": doc(), "page": { "type": "integer", "minimum": 1 }, "index": { "type": "integer", "minimum": 1 } }), &["doc", "page", "index"])),
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
//! A link's JavaScript action through the tool table (#2081, ported): link_list shows the
|
||||
//! script, link_run_javascript runs it with ordered answers and reports requests, never
|
||||
//! carrying them out.
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn link_run_javascript_answers_menus_and_reports_requests() {
|
||||
let script = "var c = app.popUpMenuEx\\({cName:'Red', cReturn:'r'}, {cName:'Blue', cReturn:'b'}\\); getField\\('log'\\).value = c + ':' + event.type; app.openDoc\\('other.pdf'\\);";
|
||||
let pdf = pdf_of(&[
|
||||
"<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [4 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 /Link /Rect [20 20 180 60] /Border [0 0 0] /A << /S /JavaScript /JS ({script}) >> >>"),
|
||||
"<< /Type /Annot /Subtype /Link /Rect [20 80 180 120] /Border [0 0 0] /A << /S /JavaScript /JS (missing\\(\\);) >> >>".into(),
|
||||
]);
|
||||
let dir = std::env::temp_dir().join(format!("pdfcraft-link-js-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
std::fs::write(dir.join("link.pdf"), pdf).unwrap();
|
||||
let mut a = Automation::new().with_root(&dir).unwrap();
|
||||
let doc = tool(&mut a, "doc_open", json!({ "path": "link.pdf" }))["doc"].as_u64().unwrap();
|
||||
let info = tool(&mut a, "doc_info", json!({ "doc": doc }));
|
||||
assert!(info.to_string().contains("\"javascript\""), "links show their script: {info}");
|
||||
let log = |a: &mut Automation| {
|
||||
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()
|
||||
};
|
||||
|
||||
let out = tool(&mut a, "link_run_javascript", json!({ "doc": doc, "page": 1, "index": 2, "answers": ["b"] }));
|
||||
assert_eq!(out["error"], Value::Null, "{out}");
|
||||
assert_eq!(out["requests"], json!([{ "open_doc": "other.pdf" }]), "{out}");
|
||||
assert_eq!(log(&mut a), "b:Link");
|
||||
|
||||
let failed = tool(&mut a, "link_run_javascript", json!({ "doc": doc, "page": 1, "index": 3 }));
|
||||
assert!(failed["error"].is_string(), "{failed}");
|
||||
assert!(a.call("link_run_javascript", &json!({ "doc": doc, "page": 1, "index": 1 })).is_err(), "a widget is no JavaScript link");
|
||||
assert!(a.call("link_run_javascript", &json!({ "doc": doc, "page": 0, "index": 2 })).is_err());
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
@@ -13,6 +13,7 @@ mod document_actions;
|
||||
mod find_text;
|
||||
mod form_bulk;
|
||||
mod hostile_args;
|
||||
mod link_javascript;
|
||||
mod list_selection;
|
||||
mod model_3d;
|
||||
mod model_render;
|
||||
|
||||
+133
-2
@@ -172,6 +172,9 @@ impl JsOutput {
|
||||
Request::OpenAttachment { name, .. } => {
|
||||
notes.push(format!("A field script asked to open the attached file {name}; only a button may open an attached file"))
|
||||
}
|
||||
Request::OpenDoc(path) => notes.push(format!(
|
||||
"A field script asked to open the document {path}; only a button, a link or the console may open another document"
|
||||
)),
|
||||
r => self.requests.push(r.clone()),
|
||||
}
|
||||
}
|
||||
@@ -1642,9 +1645,122 @@ mod caption_tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// A failed link action commits nothing: its field changes, requests and layer changes are
|
||||
/// dropped (the realm put its timers back), its alerts, console lines and error kept (#2081).
|
||||
fn drop_failed_link_effects(o: &mut Outcome) {
|
||||
if o.error.is_some() {
|
||||
o.changed.clear();
|
||||
o.formatted.clear();
|
||||
o.requests.clear();
|
||||
o.layers.clear();
|
||||
}
|
||||
}
|
||||
|
||||
impl Session {
|
||||
/// A read-only document takes none of a link's field changes or resets, but its other
|
||||
/// requests (go to a page, say) still apply: the changes are dropped and, if there were any,
|
||||
/// the outcome says why, as an edit there would (#2081).
|
||||
fn keep_read_only_link_requests(&self, id: DocId, states: &[pdfcraft_js::FieldState], o: &mut Outcome) {
|
||||
let Some(doc) = self.get(id).filter(|d| d.editor.is_none()) else { return };
|
||||
let resets = o.requests.iter().any(|r| matches!(r, Request::Reset(_)));
|
||||
let edited = resets || !changes(states, &o.changed, &o.formatted).is_empty();
|
||||
o.changed.clear();
|
||||
o.formatted.clear();
|
||||
o.requests.retain(|r| !matches!(r, Request::Reset(_)));
|
||||
if edited && o.error.is_none() {
|
||||
o.error = Some(EditError::ReadOnly(doc.read_only_reason.clone().unwrap_or_default()).to_string());
|
||||
}
|
||||
}
|
||||
|
||||
/// The job for link `index` (its raw annotation index) on page `page` (both 0-based) when
|
||||
/// its action is JavaScript: event "Link"/"Mouse Up" with the document as its target, on
|
||||
/// the link's page.
|
||||
fn link_job(
|
||||
&self,
|
||||
id: DocId,
|
||||
page: usize,
|
||||
index: usize,
|
||||
) -> Result<(pdfcraft_js::session::Job, std::sync::Arc<Vec<pdfcraft_js::FieldState>>), EditError> {
|
||||
if self.js_off {
|
||||
return Err(EditError::Invalid("JavaScript is turned off (Preferences ▸ JavaScript)".into()));
|
||||
}
|
||||
let doc = self.get(id).ok_or(EditError::NoDocument)?;
|
||||
let link = doc.info.links.iter().find(|l| l.page == page && l.index == index).ok_or_else(|| EditError::Invalid("link not found".into()))?;
|
||||
let pdfcraft_render::LinkTarget::JavaScript(script) = &link.target else {
|
||||
return Err(EditError::Invalid("the link has no JavaScript action".into()));
|
||||
};
|
||||
// A read-only document's links run too (#2081): a script that changes a field then fails
|
||||
// to commit, as an edit there does, while its requests (go to a page, say) still apply.
|
||||
let cos = match &doc.editor {
|
||||
Some(editor) => editor.cos.clone(),
|
||||
None => pdfcraft_cos::Document::open_with_password(doc.bytes.clone(), doc.password.as_deref())
|
||||
.map_err(|e| EditError::Invalid(e.to_string()))?,
|
||||
};
|
||||
let cos = &cos;
|
||||
let mut runner = JsRunner::new(cos, &doc.name, self.field_highlight);
|
||||
runner.doc.zoom = doc.js_zoom;
|
||||
runner.doc.page = page;
|
||||
// As a button's script: the document's layers, and the fields as page actions and timers
|
||||
// show them (#169).
|
||||
runner.doc.layers = crate::live_scripts::js_layers(&doc.info);
|
||||
let fields = pdfcraft_forms::fields(cos);
|
||||
let states = std::sync::Arc::new(fields.iter().map(|f| crate::live_scripts::live_state(doc, cos, f, &runner.doc)).collect::<Vec<_>>());
|
||||
let job = pdfcraft_js::session::Job {
|
||||
script: script.clone(),
|
||||
event: pdfcraft_js::Event { kind: "Link".into(), ..pdfcraft_js::Event::doc("Mouse Up") },
|
||||
doc: runner.doc,
|
||||
fields: states.clone(),
|
||||
doc_scripts: runner.doc_scripts,
|
||||
calculations: calculation_scripts(cos, &fields),
|
||||
timer: None,
|
||||
};
|
||||
Ok((job, states))
|
||||
}
|
||||
|
||||
/// Run link `index` on page `page` (0-based; the index is the link's place in the page's
|
||||
/// annotations) in the document's script realm, answering its prompts from `answers` in
|
||||
/// order (none: cancelled). Field changes and `resetForm` are applied as one undoable step,
|
||||
/// unless the script failed: then nothing is committed and its requests are dropped. In a
|
||||
/// read-only document the requests apply and the changes are refused.
|
||||
pub fn run_link_javascript(&mut self, id: DocId, page: usize, index: usize, answers: &[Option<String>]) -> Result<Outcome, EditError> {
|
||||
let (job, states) = self.link_job(id, page, index)?;
|
||||
let runtime = self.get(id).ok_or(EditError::NoDocument)?.js_runtime.clone();
|
||||
let mut o = runtime.lock().map_err(|_| EditError::Invalid("document script runtime is unavailable".into()))?.run(job, answers);
|
||||
drop_failed_link_effects(&mut o);
|
||||
self.keep_read_only_link_requests(id, &states, &mut o);
|
||||
let discard = o.execution_refused && !o.realm_untouched;
|
||||
let result = self.apply_script_outcome(id, &states, o, false);
|
||||
if discard || result.is_err() {
|
||||
self.invalidate_javascript(id);
|
||||
}
|
||||
result
|
||||
}
|
||||
}
|
||||
|
||||
/// The local file `app.openDoc` names, resolved as a link to another PDF is
|
||||
/// ([`crate::resolve_remote_document_path`], relative to the document `source`'s folder). No
|
||||
/// file is touched. On Windows, Acrobat's device-independent `/C/dir/file.pdf` (and `/C:/…`)
|
||||
/// reads as `C:/dir/file.pdf` (#2081).
|
||||
pub fn resolve_javascript_document_path(source: Option<&std::path::Path>, raw: &str) -> Result<std::path::PathBuf, crate::RemoteDocumentPathError> {
|
||||
#[cfg(windows)]
|
||||
let normalized = {
|
||||
let b = raw.as_bytes();
|
||||
match (b.first(), b.get(1), b.get(2), b.get(3)) {
|
||||
(Some(b'/'), Some(d), Some(b'/'), _) if d.is_ascii_alphabetic() => raw.get(2..).map(|rest| format!("{}:{rest}", char::from(*d))),
|
||||
(Some(b'/'), Some(d), Some(b':'), Some(b'/')) if d.is_ascii_alphabetic() => raw.get(1..).map(str::to_string),
|
||||
_ => None,
|
||||
}
|
||||
};
|
||||
#[cfg(windows)]
|
||||
let raw = normalized.as_deref().unwrap_or(raw);
|
||||
crate::resolve_remote_document_path(source, raw)
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub struct PendingScript {
|
||||
pub id: DocId,
|
||||
/// A link action's: a failed one commits nothing.
|
||||
link: bool,
|
||||
generation: u64,
|
||||
runtime: Runtime,
|
||||
states: std::sync::Arc<Vec<pdfcraft_js::FieldState>>,
|
||||
@@ -1694,7 +1810,16 @@ impl Session {
|
||||
};
|
||||
let receiver =
|
||||
doc.js_runtime.lock().map_err(|_| EditError::Invalid("script runtime unavailable".into()))?.start(job).map_err(EditError::Invalid)?;
|
||||
Ok(PendingScript { id, generation: doc.generation, runtime: doc.js_runtime.clone(), states, receiver })
|
||||
Ok(PendingScript { id, link: false, generation: doc.generation, runtime: doc.js_runtime.clone(), states, receiver })
|
||||
}
|
||||
/// Start link `index` on page `page` as [`Session::run_link_javascript`] runs it, without
|
||||
/// blocking on its prompts; [`Session::finish_javascript`] commits nothing if it failed.
|
||||
pub fn start_link_javascript(&mut self, id: DocId, page: usize, index: usize) -> Result<PendingScript, EditError> {
|
||||
let (job, states) = self.link_job(id, page, index)?;
|
||||
let doc = self.get(id).ok_or(EditError::NoDocument)?;
|
||||
let receiver =
|
||||
doc.js_runtime.lock().map_err(|_| EditError::Invalid("script runtime unavailable".into()))?.start(job).map_err(EditError::Invalid)?;
|
||||
Ok(PendingScript { id, link: true, generation: doc.generation, runtime: doc.js_runtime.clone(), states, receiver })
|
||||
}
|
||||
/// Discard only the captured runtime, preserving a newer realm created by an intervening edit.
|
||||
pub fn discard_javascript(&mut self, p: &PendingScript) {
|
||||
@@ -1716,12 +1841,18 @@ impl Session {
|
||||
}
|
||||
|
||||
/// Apply a current worker result, or discard it and reset the realm when the document changed.
|
||||
pub fn finish_javascript(&mut self, p: PendingScript, out: Outcome) -> Result<Outcome, EditError> {
|
||||
pub fn finish_javascript(&mut self, p: PendingScript, mut out: Outcome) -> Result<Outcome, EditError> {
|
||||
if p.link {
|
||||
drop_failed_link_effects(&mut out);
|
||||
}
|
||||
if !self.script_pending_current(&p) {
|
||||
self.discard_javascript(&p);
|
||||
return Err(EditError::Invalid("document changed while the script was running".into()));
|
||||
}
|
||||
p.runtime.lock().map_err(|_| EditError::Invalid("script runtime unavailable".into()))?.acknowledge_completion();
|
||||
if p.link {
|
||||
self.keep_read_only_link_requests(p.id, &p.states, &mut out);
|
||||
}
|
||||
let discard = out.execution_refused && !out.realm_untouched;
|
||||
let result = self.apply_script_outcome(p.id, &p.states, out, false);
|
||||
if discard || result.is_err() {
|
||||
|
||||
@@ -205,4 +205,6 @@ pub const MAX_STAMP_LINE: usize = 200;
|
||||
#[cfg(test)]
|
||||
mod document_actions_tests;
|
||||
#[cfg(test)]
|
||||
mod link_javascript_tests;
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -0,0 +1,227 @@
|
||||
//! A link's JavaScript action (ported from #2081): it runs in the document's script realm as
|
||||
//! event "Link"/"Mouse Up" with the document as its target, on the link's page; a failed one
|
||||
//! commits nothing; field scripts may not open another document.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::{DocId, Edit, Session, js};
|
||||
|
||||
/// 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
|
||||
}
|
||||
|
||||
/// Two pages; page 2 has a text field `log` (4) and, as its second annotation, a link (7) whose
|
||||
/// action runs `script`; the document script defines `note(s)`.
|
||||
fn linked(script: &str) -> Vec<u8> {
|
||||
linked_with(script, Extra::default())
|
||||
}
|
||||
|
||||
/// Entries added to [`linked`]'s objects, and objects added from 9 on.
|
||||
#[derive(Default)]
|
||||
struct Extra<'a> {
|
||||
catalog: &'a str,
|
||||
form: &'a str,
|
||||
field: &'a str,
|
||||
objects: &'a [&'a str],
|
||||
}
|
||||
|
||||
/// [`linked`], with `extra` added.
|
||||
fn linked_with(script: &str, extra: Extra) -> Vec<u8> {
|
||||
let Extra { catalog, form, field, objects } = extra;
|
||||
let mut objs = vec![
|
||||
format!(
|
||||
"<< /Type /Catalog /Pages 2 0 R {catalog} /Names << /JavaScript << /Names [(init) 8 0 R] >> >> \
|
||||
/AcroForm << /Fields [4 0 R] /DA (/Helv 0 Tf 0 g) /DR << /Font << /Helv 5 0 R >> >> {form} >> >>"
|
||||
),
|
||||
"<< /Type /Pages /Kids [6 0 R 3 0 R] /Count 2 /MediaBox [0 0 200 300] >>".into(),
|
||||
"<< /Type /Page /Parent 2 0 R /Annots [4 0 R 7 0 R] >>".into(),
|
||||
format!("<< /Type /Annot /Subtype /Widget /FT /Tx /T (log) /V () /Rect [20 250 180 270] /P 3 0 R /F 4 {field} >>"),
|
||||
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>".into(),
|
||||
"<< /Type /Page /Parent 2 0 R >>".into(),
|
||||
format!("<< /Type /Annot /Subtype /Link /Rect [20 20 180 60] /Border [0 0 0] /A << /S /JavaScript /JS ({script}) >> >>"),
|
||||
"<< /S /JavaScript /JS (function note\\(s\\) { getField\\('log'\\).value += s; }) >>".into(),
|
||||
];
|
||||
objs.extend(objects.iter().map(|o| (*o).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(",")
|
||||
}
|
||||
|
||||
fn open(script: &str) -> (Session, DocId) {
|
||||
open_pdf(linked(script))
|
||||
}
|
||||
|
||||
fn open_pdf(pdf: Vec<u8>) -> (Session, DocId) {
|
||||
let mut s = Session::new();
|
||||
let id = s.open("link.pdf", None, Arc::new(pdf), None).unwrap();
|
||||
s.take_js_output(id);
|
||||
(s, id)
|
||||
}
|
||||
|
||||
/// The link reads as a JavaScript link, and its action runs as "Link"/"Mouse Up" on its page
|
||||
/// with the document as target and the document scripts defined; its change is one Undo step.
|
||||
#[test]
|
||||
fn a_link_runs_its_javascript_in_the_document_realm() {
|
||||
let script = "note\\(event.type + ':' + event.name + ':' + \\(event.target === this\\) + ':' + this.pageNum\\); this.pageNum = 0;";
|
||||
let (mut s, id) = open(script);
|
||||
let link = s.get(id).unwrap().info.links.iter().find(|l| matches!(l.target, pdfcraft_render::LinkTarget::JavaScript(_))).cloned().unwrap();
|
||||
assert_eq!((link.page, link.index), (1, 1));
|
||||
let o = s.run_link_javascript(id, 1, 1, &[]).unwrap();
|
||||
assert_eq!(o.error, None);
|
||||
assert_eq!(log(&s, id), "Link:Mouse Up:true:1");
|
||||
assert_eq!(o.requests, [js::Request::GoToPage(0)]);
|
||||
s.undo(id).unwrap();
|
||||
assert_eq!(log(&s, id), "");
|
||||
assert!(s.run_link_javascript(id, 1, 0, &[]).is_err(), "the field is no link");
|
||||
s.set_javascript(false);
|
||||
assert!(s.run_link_javascript(id, 1, 1, &[]).is_err());
|
||||
}
|
||||
|
||||
/// A link script that fails commits nothing: neither its field change nor its requests.
|
||||
#[test]
|
||||
fn a_failed_link_script_commits_nothing() {
|
||||
let (mut s, id) = open("note\\('x'\\); this.pageNum = 0; app.openDoc\\('b.pdf'\\); console.println\\('said'\\); missing\\(\\);");
|
||||
let o = s.run_link_javascript(id, 1, 1, &[]).unwrap();
|
||||
assert!(o.error.is_some());
|
||||
assert_eq!(log(&s, id), "");
|
||||
assert!(o.requests.is_empty(), "{:?}", o.requests);
|
||||
assert_eq!(o.console, ["said"], "what it said is kept");
|
||||
assert!(!s.get(id).unwrap().dirty);
|
||||
}
|
||||
|
||||
/// A link's popup menu waits for its answer, and openDoc comes back as a request only.
|
||||
#[test]
|
||||
fn a_link_menu_is_answered_and_open_doc_is_only_requested() {
|
||||
let (mut s, id) = open("note\\(app.popUpMenuEx\\({cName:'A', cReturn:'a'}, {cName:'B', cReturn:'b'}\\)\\); app.openDoc\\('other.pdf'\\);");
|
||||
let o = s.run_link_javascript(id, 1, 1, &[Some("b".into())]).unwrap();
|
||||
assert_eq!(o.error, None);
|
||||
assert_eq!(log(&s, id), "b");
|
||||
assert_eq!(o.requests, [js::Request::OpenDoc("other.pdf".into())]);
|
||||
}
|
||||
|
||||
/// The interactive start commits through `finish_javascript` with the same failure policy, and
|
||||
/// a result for a document that changed meanwhile is refused.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
#[test]
|
||||
fn a_started_link_script_finishes_like_a_run_one() {
|
||||
let wait = |p: &js::PendingScript| loop {
|
||||
match p.receiver.recv_timeout(std::time::Duration::from_secs(10)).unwrap() {
|
||||
js::InteractiveEvent::Prompt(_, reply) => reply.send(Some("a".into())).unwrap(),
|
||||
js::InteractiveEvent::Complete(o) => break o,
|
||||
}
|
||||
};
|
||||
let (mut s, id) = open("note\\(app.popUpMenuEx\\({cName:'A', cReturn:'a'}\\)\\);");
|
||||
let p = s.start_link_javascript(id, 1, 1).unwrap();
|
||||
let o = wait(&p);
|
||||
s.finish_javascript(p, o).unwrap();
|
||||
assert_eq!(log(&s, id), "a");
|
||||
let p = s.start_link_javascript(id, 1, 1).unwrap();
|
||||
let mut o = wait(&p);
|
||||
o.error = Some("failed".into());
|
||||
s.finish_javascript(p, o).unwrap();
|
||||
assert_eq!(log(&s, id), "a", "a failed link commits nothing");
|
||||
let p = s.start_link_javascript(id, 1, 1).unwrap();
|
||||
let o = wait(&p);
|
||||
s.apply(id, Edit::RotatePages { pages: vec![0], degrees: 90 }).unwrap();
|
||||
assert!(s.finish_javascript(p, o).is_err(), "the document changed meanwhile");
|
||||
}
|
||||
|
||||
/// A read-only document's link still runs: requests apply, a field change fails to commit.
|
||||
#[test]
|
||||
fn a_read_only_documents_link_runs() {
|
||||
let (mut s, id) = open("this.pageNum = 0;");
|
||||
s.docs.iter_mut().find(|d| d.id == id).unwrap().editor = None;
|
||||
let o = s.run_link_javascript(id, 1, 1, &[]).unwrap();
|
||||
assert_eq!((o.error, o.requests), (None, vec![js::Request::GoToPage(0)]));
|
||||
}
|
||||
|
||||
/// A field's own script (here a format script) may not open another document: it is noted on
|
||||
/// the console and dropped; a button's may.
|
||||
#[test]
|
||||
fn only_an_explicit_action_may_open_a_document() {
|
||||
let (mut s, id) = open("");
|
||||
s.apply(id, Edit::SetFieldScript { name: "log".into(), event: "format".into(), script: Some("app.openDoc('other.pdf');".into()) }).unwrap();
|
||||
s.take_js_output(id);
|
||||
s.apply(id, Edit::SetFieldValue { name: "log".into(), value: crate::FieldValue::Text("4".into()) }).unwrap();
|
||||
let out = s.take_js_output(id);
|
||||
assert!(out.requests.iter().all(|r| !matches!(r, js::Request::OpenDoc(_))), "{out:?}");
|
||||
assert!(out.console.iter().any(|c| c.contains("open the document other.pdf")), "{out:?}");
|
||||
}
|
||||
|
||||
/// A link that fails before it is done leaves no timer it set and clears none: the time-out
|
||||
/// never runs and the interval it cleared still does (review on #7144).
|
||||
#[test]
|
||||
fn a_failed_link_leaves_the_timers_as_they_were() {
|
||||
let (mut s, id) = open("app.clearInterval\\(kept\\); app.setTimeOut\\('note\\(1\\)', 5\\); missing\\(\\);");
|
||||
s.show_page(id, 1, 0.0);
|
||||
let set = s.run_javascript(id, "var kept = app.setInterval('note(2)', 1000);", None).unwrap();
|
||||
assert_eq!(set.error, None);
|
||||
assert_eq!(s.next_timer(id), Some(1000.0));
|
||||
let o = s.run_link_javascript(id, 1, 1, &[]).unwrap();
|
||||
assert!(o.error.is_some());
|
||||
assert_eq!(s.next_timer(id), Some(1000.0), "the interval stays and the time-out is gone");
|
||||
s.run_timers(id, 1000.0);
|
||||
assert_eq!(log(&s, id), "2");
|
||||
}
|
||||
|
||||
/// A link that fails before it is done turns no layer (review on #7144).
|
||||
#[test]
|
||||
fn a_failed_link_turns_no_layer() {
|
||||
let pdf = linked_with(
|
||||
"this.getOCGs\\(\\)[0].state = false; missing\\(\\);",
|
||||
Extra {
|
||||
catalog: "/OCProperties << /OCGs [9 0 R] /D << /Order [9 0 R] >> >>",
|
||||
objects: &["<< /Type /OCG /Name (Red) >>"],
|
||||
..Extra::default()
|
||||
},
|
||||
);
|
||||
let (mut s, id) = open_pdf(pdf);
|
||||
assert!(s.get(id).unwrap().info.layers[0].visible);
|
||||
let o = s.run_link_javascript(id, 1, 1, &[]).unwrap();
|
||||
assert!(o.error.is_some());
|
||||
assert!(o.layers.is_empty(), "{:?}", o.layers);
|
||||
assert!(s.get(id).unwrap().info.layers[0].visible);
|
||||
}
|
||||
|
||||
/// In a read-only document a link's request still applies when its field change can't, and
|
||||
/// the outcome says why the change wasn't made (review on #7144).
|
||||
#[test]
|
||||
fn a_read_only_documents_link_goes_to_its_page_after_a_field_change() {
|
||||
let (mut s, id) = open("note\\('x'\\); this.pageNum = 0;");
|
||||
s.docs.iter_mut().find(|d| d.id == id).unwrap().editor = None;
|
||||
let o = s.run_link_javascript(id, 1, 1, &[]).unwrap();
|
||||
assert_eq!(o.requests, [js::Request::GoToPage(0)]);
|
||||
let read_only = crate::EditError::ReadOnly(String::new()).to_string();
|
||||
assert_eq!(o.error.as_deref(), Some(read_only.as_str()));
|
||||
assert_eq!(log(&s, id), "");
|
||||
}
|
||||
|
||||
/// A field's calculation that a link sets off (`calculateNow`) may not open another document,
|
||||
/// as when it runs on its own; the link itself still may (review on #7144).
|
||||
#[test]
|
||||
fn a_calculation_a_link_runs_may_not_open_a_document() {
|
||||
let pdf = linked_with(
|
||||
"this.calculateNow\\(\\); app.openDoc\\('own.pdf'\\);",
|
||||
Extra { form: "/CO [4 0 R]", field: "/AA << /C << /S /JavaScript /JS (app.openDoc\\('calc.pdf'\\);) >> >>", ..Extra::default() },
|
||||
);
|
||||
let (mut s, id) = open_pdf(pdf);
|
||||
let o = s.run_link_javascript(id, 1, 1, &[]).unwrap();
|
||||
assert_eq!(o.error, None);
|
||||
assert_eq!(o.requests, [js::Request::OpenDoc("own.pdf".into())]);
|
||||
assert!(o.console.iter().any(|c| c.contains("open the document calc.pdf")), "{:?}", o.console);
|
||||
}
|
||||
@@ -140,6 +140,49 @@ 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"));
|
||||
}
|
||||
let shared = host(ctx)?;
|
||||
let mut h = shared.borrow_mut();
|
||||
// A field's calculation runs unasked, here through `calculateNow`: as when it runs on its
|
||||
// own (`JsOutput::absorb`), it may not open another document.
|
||||
if h.calculate_depth > 0 {
|
||||
h.print(format!("A field script asked to open the document {path}; only a button, a link or the console may open another document"));
|
||||
} else {
|
||||
h.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.
|
||||
@@ -551,6 +594,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(crate::timers::set_time_out), js_string!("setTimeOut"), 2)
|
||||
.function(NativeFunction::from_fn_ptr(crate::timers::set_interval), js_string!("setInterval"), 2)
|
||||
@@ -619,13 +663,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())
|
||||
@@ -634,7 +681,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())
|
||||
|
||||
@@ -82,12 +82,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")),
|
||||
|
||||
@@ -319,6 +319,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 {
|
||||
|
||||
@@ -182,8 +182,11 @@ impl Realm {
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
// A link action that fails commits nothing (#2081), timers included: the realm's are saved
|
||||
// once the document scripts ran, and put back if the link's own script fails.
|
||||
let link = job.event.kind == "Link";
|
||||
let Some(ctx) = self.context.as_mut() else { return failed("script realm unavailable".into()) };
|
||||
if initialize {
|
||||
let (mut out, saved) = if initialize {
|
||||
let mut initial = crate::run::run_in_context(
|
||||
ctx,
|
||||
"",
|
||||
@@ -211,6 +214,7 @@ impl Realm {
|
||||
(h.fields.clone(), h.reset_calls, h.calculation_evaluations)
|
||||
})
|
||||
.unwrap_or((job.fields, 0, 0));
|
||||
let saved = link.then(|| crate::timers::save(ctx)).flatten();
|
||||
let mut out = crate::run::run_in_context(
|
||||
ctx,
|
||||
&job.script,
|
||||
@@ -249,9 +253,10 @@ impl Realm {
|
||||
if out.error.is_none() {
|
||||
out.error = initial.error;
|
||||
}
|
||||
out
|
||||
(out, saved)
|
||||
} else {
|
||||
crate::run::run_in_context(
|
||||
let saved = link.then(|| crate::timers::save(ctx)).flatten();
|
||||
let out = crate::run::run_in_context(
|
||||
ctx,
|
||||
&job.script,
|
||||
&job.event,
|
||||
@@ -268,8 +273,16 @@ impl Realm {
|
||||
call: None,
|
||||
},
|
||||
Limits::default(),
|
||||
)
|
||||
);
|
||||
(out, saved)
|
||||
};
|
||||
if out.error.is_some()
|
||||
&& let Some(saved) = saved
|
||||
{
|
||||
crate::timers::restore(ctx, saved);
|
||||
out.timers = crate::timers::report(ctx);
|
||||
}
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2750,6 +2750,77 @@ fn af_simple_calculate_bounds_selector_work_before_expansion() {
|
||||
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(),
|
||||
timer: None,
|
||||
};
|
||||
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);
|
||||
}
|
||||
|
||||
/// `getOCGs` lists the document's layers; setting `state` turns one, which the run reports, and
|
||||
/// a document without layers gives `null` (#169, from #4751).
|
||||
#[test]
|
||||
|
||||
+16
-1
@@ -35,6 +35,7 @@ pub(crate) enum Code {
|
||||
Function(JsObject),
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct Entry {
|
||||
/// The object `set*` returned, which `clear*` is given back.
|
||||
handle: JsObject,
|
||||
@@ -44,7 +45,7 @@ struct Entry {
|
||||
}
|
||||
|
||||
/// A realm's timers by id, in the order they were set (ids only grow).
|
||||
#[derive(Default)]
|
||||
#[derive(Clone, Default)]
|
||||
pub(crate) struct Timers {
|
||||
last: u32,
|
||||
table: BTreeMap<u32, Entry>,
|
||||
@@ -66,6 +67,20 @@ pub(crate) fn report(ctx: &Context) -> Vec<Timer> {
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// The realm's timers as they stand, to put back with [`restore`]; `None` in a one-off run.
|
||||
pub(crate) fn save(ctx: &Context) -> Option<Timers> {
|
||||
ctx.get_data::<Shared>().map(|t| t.borrow().clone())
|
||||
}
|
||||
|
||||
/// Put the realm's timers back as [`save`] found them: those set since go, those cleared since
|
||||
/// come back. Ids stay unused once given out.
|
||||
pub(crate) fn restore(ctx: &Context, saved: Timers) {
|
||||
if let Some(timers) = ctx.get_data::<Shared>() {
|
||||
let mut timers = timers.borrow_mut();
|
||||
timers.table = saved.table;
|
||||
}
|
||||
}
|
||||
|
||||
/// What timer `id` runs now that it fell due; a `setTimeOut` timer is gone after this. `None`
|
||||
/// when it was cleared (or the realm has no timers).
|
||||
pub(crate) fn due(ctx: &Context, id: u32) -> Option<Code> {
|
||||
|
||||
@@ -6,6 +6,63 @@ use super::model::{
|
||||
use super::navigation::text_string;
|
||||
use super::{Dictionary, DocInfo, Entry, HashMap, HashSet, Inspector, Object, ObjectId};
|
||||
|
||||
/// Negative cache of failed script-stream decompressions during one structure inspection.
|
||||
/// Callers must supply stream addresses that stay stable for that inspection; these are
|
||||
/// opaque identities, never dereferenced here. Do not reuse the cache across documents,
|
||||
/// edits or inspections, where an address may identify a different stream.
|
||||
/// FIFO eviction bounds it to 4096 identities and allows an evicted failure to be retried;
|
||||
/// a cache hit does not refresh its position. This bounds retained failures; streams that
|
||||
/// decode are remembered in [`LinkScripts`] instead.
|
||||
#[derive(Default)]
|
||||
pub(super) struct RejectedJavaScript {
|
||||
identities: HashSet<*const lopdf::Stream>,
|
||||
order: std::collections::VecDeque<*const lopdf::Stream>,
|
||||
}
|
||||
|
||||
impl RejectedJavaScript {
|
||||
pub(super) const CAPACITY: usize = 4096;
|
||||
|
||||
pub(super) fn contains(&self, identity: *const lopdf::Stream) -> bool {
|
||||
self.identities.contains(&identity)
|
||||
}
|
||||
|
||||
pub(super) fn insert(&mut self, identity: *const lopdf::Stream) {
|
||||
if self.identities.contains(&identity) {
|
||||
return;
|
||||
}
|
||||
if self.order.len() >= Self::CAPACITY
|
||||
&& let Some(oldest) = self.order.pop_front()
|
||||
{
|
||||
self.identities.remove(&oldest);
|
||||
}
|
||||
self.identities.insert(identity);
|
||||
self.order.push_back(identity);
|
||||
}
|
||||
}
|
||||
|
||||
/// What link JavaScript an inspection may still keep: 4 MiB of source across the document,
|
||||
/// the streams that failed to decode, and the source of those that did.
|
||||
pub(super) struct LinkScripts {
|
||||
remaining: usize,
|
||||
rejected: RejectedJavaScript,
|
||||
/// Each stream's source once decoded, by its address (see [`RejectedJavaScript`]): many links
|
||||
/// can share one stream, whose decoding costs its encoded length however short the source
|
||||
/// (review on #7144). Every link keeping a source still spends its length from `remaining`,
|
||||
/// so what is kept here stays within the budget, plus at most one source.
|
||||
decoded: HashMap<*const lopdf::Stream, String>,
|
||||
/// Whether the warning that the budget ran out has been given.
|
||||
limit_reported: bool,
|
||||
}
|
||||
|
||||
impl Default for LinkScripts {
|
||||
fn default() -> Self {
|
||||
Self { remaining: 4 << 20, rejected: RejectedJavaScript::default(), decoded: HashMap::new(), limit_reported: false }
|
||||
}
|
||||
}
|
||||
|
||||
/// Most bytes of one link's script string or decompressed stream.
|
||||
const MAX_LINK_SCRIPT: usize = 256 << 10;
|
||||
|
||||
/// At most this many `/State` entries of a set-layer-visibility action are read.
|
||||
const MAX_LAYER_STATE: usize = 1024;
|
||||
|
||||
@@ -124,6 +181,8 @@ impl<'a> Inspector<'a> {
|
||||
// Once the comment budget is spent, comments are no longer built, but links and attachments
|
||||
// are still listed.
|
||||
let mut comments_full = false;
|
||||
// Link JavaScript has its own source budget, apart from link text (#2081).
|
||||
let mut scripts = LinkScripts::default();
|
||||
for (page, index, a) in entries {
|
||||
let Some(d) = self.dict(a) else { continue };
|
||||
// An annotation's `/Type`, when present, is `/Annot` (ISO 32000-2 §12.5.2, Table 166):
|
||||
@@ -144,7 +203,7 @@ impl<'a> Inspector<'a> {
|
||||
info.attachments.push(self.attachment(fs, AttachmentSource::Annotation { page, index }, text_budget, fallback));
|
||||
}
|
||||
if subtype == "Link" {
|
||||
self.collect_link(info, (page, index), d, text_budget, &mut unfit_quads);
|
||||
self.collect_link(info, (page, index), d, text_budget, &mut unfit_quads, &mut scripts);
|
||||
continue;
|
||||
}
|
||||
// Not comments: form widgets, pop-ups and non-markup annotations such as the
|
||||
@@ -307,7 +366,9 @@ impl<'a> Inspector<'a> {
|
||||
/// goes, else what its action `/A` does ([`Self::action_target`]). `None` when it has no
|
||||
/// rectangle, or a destination or action that leads nowhere. `base` is the catalog's
|
||||
/// `/URI /Base`, which a relative URI action is resolved against. `unfit_quads` holds the
|
||||
/// indirect `/QuadPoints` arrays already found too large for the budget.
|
||||
/// indirect `/QuadPoints` arrays already found too large for the budget. A JavaScript
|
||||
/// action's source is kept as data, never run here, within `scripts` ([`Self::link_script`]).
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn link(
|
||||
&self,
|
||||
page: usize,
|
||||
@@ -316,13 +377,17 @@ impl<'a> Inspector<'a> {
|
||||
base: Option<&str>,
|
||||
text_budget: &mut usize,
|
||||
unfit_quads: &mut HashSet<ObjectId>,
|
||||
scripts: &mut LinkScripts,
|
||||
) -> Option<Link> {
|
||||
let rect = rect4(self.resolve(d.get(b"Rect").ok()?));
|
||||
let action = d.get(b"A").ok().and_then(|a| self.dict(a));
|
||||
let target = if let Ok(dest) = d.get(b"Dest") {
|
||||
let (page, view) = self.dest(dest, 0)?;
|
||||
LinkTarget::Page(page, view)
|
||||
} else if let Some(a) = action.filter(|a| self.name(a, b"S").as_deref() == Some("JavaScript")) {
|
||||
self.link_script(a, scripts).map_or_else(|| LinkTarget::Other("JavaScript".into()), LinkTarget::JavaScript)
|
||||
} else {
|
||||
match self.action_target(d.get(b"A").ok().and_then(|a| self.dict(a))?, text_budget)? {
|
||||
match self.action_target(action?, text_budget)? {
|
||||
LinkTarget::Uri(uri) => match crate::resolve_uri(base, &uri) {
|
||||
// The raw address is spent already. What the base adds is spent too, or the
|
||||
// link keeps the raw address.
|
||||
@@ -367,14 +432,25 @@ impl<'a> Inspector<'a> {
|
||||
|
||||
/// Lists the link annotation `d`, and notes the JavaScript it runs. JavaScript is read from the
|
||||
/// action, not the listed link: once the text budget is spent the action's name is not kept.
|
||||
fn collect_link(&self, info: &mut DocInfo, at: (usize, usize), d: &Dictionary, text_budget: &mut usize, unfit_quads: &mut HashSet<ObjectId>) {
|
||||
/// Warns once when the link scripts' budget has run out (#2081).
|
||||
fn collect_link(
|
||||
&self,
|
||||
info: &mut DocInfo,
|
||||
at: (usize, usize),
|
||||
d: &Dictionary,
|
||||
text_budget: &mut usize,
|
||||
unfit_quads: &mut HashSet<ObjectId>,
|
||||
scripts: &mut LinkScripts,
|
||||
) {
|
||||
info.has_javascript |= self.runs_javascript(d);
|
||||
if let Some(link) = self.link(at.0, at.1, d, info.uri_base.as_deref(), text_budget, unfit_quads) {
|
||||
if matches!(&link.target, LinkTarget::Other(s) if s == "JavaScript") {
|
||||
info.has_javascript = true;
|
||||
}
|
||||
info.links.push(link);
|
||||
let Some(link) = self.link(at.0, at.1, d, info.uri_base.as_deref(), text_budget, unfit_quads, scripts) else { return };
|
||||
let refused = matches!(&link.target, LinkTarget::Other(s) if s == "JavaScript");
|
||||
if refused && scripts.remaining == 0 && !scripts.limit_reported {
|
||||
info.warnings
|
||||
.push("Link JavaScript sources reached the 4 MiB document inspection limit; remaining JavaScript actions are unavailable.".into());
|
||||
scripts.limit_reported = true;
|
||||
}
|
||||
info.links.push(link);
|
||||
}
|
||||
|
||||
/// Whether the link's own action is a JavaScript action.
|
||||
@@ -382,6 +458,53 @@ impl<'a> Inspector<'a> {
|
||||
d.get(b"A").ok().and_then(|a| self.dict(a)).is_some_and(|a| self.name(a, b"S").as_deref() == Some("JavaScript"))
|
||||
}
|
||||
|
||||
/// The source of a link's JavaScript action (`/JS`, a string or a stream), as data. A
|
||||
/// string or decompressed stream over 256 KiB, a stream that fails to decode, or source
|
||||
/// past what is left of the document's 4 MiB is `None` (the link stays
|
||||
/// `Other("JavaScript")`); a stream is decoded once, its source or failure remembered for
|
||||
/// links sharing it. An action's `/Next` chain is not followed.
|
||||
fn link_script(&self, a: &Dictionary, scripts: &mut LinkScripts) -> Option<String> {
|
||||
if scripts.remaining == 0 {
|
||||
return None;
|
||||
}
|
||||
let source = match self.resolve(a.get(b"JS").ok()?) {
|
||||
Object::String(bytes, _) if bytes.len() <= MAX_LINK_SCRIPT => pdfcraft_cos::PdfString::literal(bytes.clone()).to_text(),
|
||||
Object::Stream(stream) => {
|
||||
// The structure's objects stay put for the whole inspection, so the resolved
|
||||
// stream's address identifies it, aliases included.
|
||||
let identity = std::ptr::from_ref(stream);
|
||||
if scripts.rejected.contains(identity) {
|
||||
return None;
|
||||
}
|
||||
if let Some(source) = scripts.decoded.get(&identity) {
|
||||
source.clone()
|
||||
} else {
|
||||
#[cfg(test)]
|
||||
JS_DECODE_COUNT.with(|n| n.set(n.get() + 1));
|
||||
// An empty `/Filter []` is no filter: the stream is the source.
|
||||
match stream.get_plain_content_with_limit(MAX_LINK_SCRIPT) {
|
||||
Ok(bytes) => {
|
||||
let source = pdfcraft_cos::PdfString::literal(bytes).to_text();
|
||||
scripts.decoded.insert(identity, source.clone());
|
||||
source
|
||||
}
|
||||
Err(_) => {
|
||||
scripts.rejected.insert(identity);
|
||||
return None;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => return None,
|
||||
};
|
||||
let Some(remaining) = scripts.remaining.checked_sub(source.len()) else {
|
||||
scripts.remaining = 0;
|
||||
return None;
|
||||
};
|
||||
scripts.remaining = remaining;
|
||||
Some(source)
|
||||
}
|
||||
|
||||
/// What an action does when a link or a bookmark runs it. Only a go-to's `/D` is a place in
|
||||
/// this document; a remote go-to's names a page or destination of another file (ISO 32000-2
|
||||
/// §12.6.4.3). `None` for an action without a type, or a go-to whose destination is not here.
|
||||
@@ -866,3 +989,118 @@ thread_local! {
|
||||
/// `/QuadPoints` arrays whose values were read.
|
||||
pub(super) static QUAD_ARRAYS_READ: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
thread_local! { static JS_DECODE_COUNT: std::cell::Cell<usize> = const { std::cell::Cell::new(0) }; }
|
||||
|
||||
#[cfg(test)]
|
||||
mod remediation_probes {
|
||||
use super::*;
|
||||
use crate::inspect::{Arc, inspect};
|
||||
use lopdf::{Document, dictionary};
|
||||
#[test]
|
||||
fn remediation_rejected_stream_cache_has_a_fixed_bound() {
|
||||
let streams: Vec<_> = (0..8192).map(|_| lopdf::Stream::new(dictionary! {}, vec![])).collect();
|
||||
let mut cache = RejectedJavaScript::default();
|
||||
for stream in &streams {
|
||||
cache.insert(std::ptr::from_ref(stream));
|
||||
assert!(cache.identities.len() <= 4096);
|
||||
assert!(cache.order.len() <= 4096);
|
||||
}
|
||||
assert!(!cache.contains(std::ptr::from_ref(streams.first().unwrap())));
|
||||
assert!(cache.contains(std::ptr::from_ref(streams.last().unwrap())));
|
||||
}
|
||||
fn shared_bad_stream(n: usize, repeated_annotation: bool) -> Vec<u8> {
|
||||
let mut d = Document::with_version("1.7");
|
||||
let pages = d.new_object_id();
|
||||
let mut stream = lopdf::Stream::new(dictionary! {}, vec![b'x'; 300 << 10]);
|
||||
stream.compress().unwrap();
|
||||
let stream_id = d.add_object(stream);
|
||||
let action = d.add_object(dictionary! {"S"=>"JavaScript","JS"=>stream_id});
|
||||
let mut annots = Vec::new();
|
||||
let mut first = None;
|
||||
for _ in 0..n {
|
||||
let annotation_action = if n == 33 {
|
||||
let alias = d.add_object(Object::Reference(stream_id));
|
||||
d.add_object(dictionary! {"S"=>"JavaScript","JS"=>alias})
|
||||
} else {
|
||||
action
|
||||
};
|
||||
let id = if let Some(id) = first.filter(|_| repeated_annotation) {
|
||||
id
|
||||
} else {
|
||||
let id = d.add_object(
|
||||
dictionary! {"Type"=>"Annot","Subtype"=>"Link","Rect"=>vec![0.into(),0.into(),50.into(),50.into()],"A"=>annotation_action},
|
||||
);
|
||||
first = Some(id);
|
||||
id
|
||||
};
|
||||
annots.push(Object::Reference(id));
|
||||
}
|
||||
let good=d.add_object(dictionary!{"Subtype"=>"Link","Rect"=>vec![0.into(),0.into(),50.into(),50.into()],"A"=>dictionary!{"S"=>"JavaScript","JS"=>Object::string_literal("console.println('valid');")}});
|
||||
annots.push(good.into());
|
||||
let page = d.add_object(dictionary! {"Type"=>"Page","Parent"=>pages,"Annots"=>annots});
|
||||
d.objects.insert(
|
||||
pages,
|
||||
dictionary! {"Type"=>"Pages","Kids"=>vec![page.into()],"Count"=>1,"MediaBox"=>vec![0.into(),0.into(),200.into(),200.into()]}.into(),
|
||||
);
|
||||
let root = d.add_object(dictionary! {"Type"=>"Catalog","Pages"=>pages});
|
||||
d.trailer.set("Root", root);
|
||||
let mut bytes = Vec::new();
|
||||
d.save_to(&mut bytes).unwrap();
|
||||
bytes
|
||||
}
|
||||
#[test]
|
||||
fn remediation_rejected_stream_decode_is_cached_and_preserves_budget() {
|
||||
for (n, repeated) in [(1, false), (33, false), (256, false), (1024, false), (1024, true)] {
|
||||
let bytes = shared_bad_stream(n, repeated);
|
||||
let len = bytes.len();
|
||||
|
||||
let mut times = Vec::new();
|
||||
let mut calls = 0;
|
||||
for _ in 0..3 {
|
||||
JS_DECODE_COUNT.with(|n| n.set(0));
|
||||
let now = std::time::Instant::now();
|
||||
let info = inspect(Arc::new(bytes.clone()), None).unwrap();
|
||||
times.push(now.elapsed().as_micros());
|
||||
calls = JS_DECODE_COUNT.with(|n| n.get());
|
||||
assert_eq!(calls, 1);
|
||||
assert_eq!(info.links.len(), if repeated { 2 } else { n + 1 });
|
||||
assert!(matches!(&info.links.last().unwrap().target,LinkTarget::JavaScript(s) if s=="console.println('valid');"));
|
||||
assert!(!info.warnings.iter().any(|w| w.contains("4 MiB")));
|
||||
}
|
||||
println!("REJECT n={n} repeated_annotation={repeated} bytes={len} decode_calls={calls} micros={times:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Links sharing a stream that decodes decode it once, even when its source is empty and
|
||||
/// spends none of the budget; a stream whose `/Filter` is an empty array is its own source
|
||||
/// (review on #7144).
|
||||
#[test]
|
||||
fn a_shared_script_stream_is_decoded_once() {
|
||||
let mut d = Document::with_version("1.7");
|
||||
let pages = d.new_object_id();
|
||||
// Whitespace decodes to nothing, at the cost of reading all of it.
|
||||
let blank = d.add_object(lopdf::Stream::new(dictionary! {"Filter"=>"ASCIIHexDecode"}, [vec![b' '; 64 << 10], b">".to_vec()].concat()));
|
||||
let raw = d.add_object(lopdf::Stream::new(dictionary! {"Filter"=>Vec::<Object>::new()}, b"app.alert(1);".to_vec()));
|
||||
let rect = || vec![0.into(), 0.into(), 50.into(), 50.into()];
|
||||
let annots: Vec<Object> = std::iter::repeat_n(blank, 100)
|
||||
.chain([raw])
|
||||
.map(|js| {
|
||||
d.add_object(dictionary! {"Type"=>"Annot","Subtype"=>"Link","Rect"=>rect(),"A"=>dictionary! {"S"=>"JavaScript","JS"=>js}}).into()
|
||||
})
|
||||
.collect();
|
||||
let page = d.add_object(dictionary! {"Type"=>"Page","Parent"=>pages,"Annots"=>annots});
|
||||
d.objects.insert(pages, dictionary! {"Type"=>"Pages","Kids"=>vec![page.into()],"Count"=>1,"MediaBox"=>rect()}.into());
|
||||
let root = d.add_object(dictionary! {"Type"=>"Catalog","Pages"=>pages});
|
||||
d.trailer.set("Root", root);
|
||||
let mut bytes = Vec::new();
|
||||
d.save_to(&mut bytes).unwrap();
|
||||
JS_DECODE_COUNT.with(|n| n.set(0));
|
||||
let info = inspect(Arc::new(bytes), None).unwrap();
|
||||
assert_eq!(JS_DECODE_COUNT.with(std::cell::Cell::get), 2, "one decode per stream");
|
||||
assert_eq!(info.links.len(), 101);
|
||||
assert!(info.links[..100].iter().all(|l| matches!(&l.target, LinkTarget::JavaScript(s) if s.is_empty())));
|
||||
assert!(matches!(&info.links[100].target, LinkTarget::JavaScript(s) if s == "app.alert(1);"), "{:?}", info.links[100].target);
|
||||
}
|
||||
}
|
||||
@@ -230,6 +230,9 @@ pub enum LinkTarget {
|
||||
/// reference ([`crate::resolve_uri`]); not yet checked, as the opener must
|
||||
/// (`pdfcraft_engine::links::document_url`).
|
||||
Uri(String),
|
||||
/// Source of a JavaScript action, decoded with a bounded stream reader (never run while
|
||||
/// inspecting).
|
||||
JavaScript(String),
|
||||
/// A set-layer-visibility action (`SetOCGState`, ISO 32000-2 §12.6.4.13): each change in
|
||||
/// order, naming the layer by its optional content group. With `preserve_rb`, a layer turned
|
||||
/// on turns off the other layers of its radio-button groups.
|
||||
|
||||
@@ -3001,3 +3001,112 @@ fn efopen_filters_named_by_the_file_open_and_older_handlers_do_not() {
|
||||
let legacy = Arc::new(efopen_pdf_with("EFOpen", false, "/V 2 /R 3 /Length 128"));
|
||||
assert!(matches!(check_pages(legacy, None, 1), Err(OpenError::NeedsPassword)));
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod javascript_links_tests {
|
||||
use super::*;
|
||||
use lopdf::dictionary;
|
||||
|
||||
#[test]
|
||||
fn shared_javascript_actions_have_a_document_wide_source_budget() {
|
||||
let mut d = Document::with_version("1.7");
|
||||
let pages = d.new_object_id();
|
||||
let page = d.new_object_id();
|
||||
let action = d.add_object(dictionary! { "S" => "JavaScript", "JS" => Object::string_literal(vec![b'x'; 256 << 10]) });
|
||||
// Distinct annotations share the action: repeated references to the same annotation
|
||||
// are intentionally deduplicated by inspection before the source budget is charged.
|
||||
let mut annots: Vec<_> = (0..33)
|
||||
.map(|_| {
|
||||
Object::Reference(d.add_object(
|
||||
dictionary! { "Type" => "Annot", "Subtype" => "Link", "Rect" => vec![0.into(),0.into(),50.into(),50.into()], "A" => action },
|
||||
))
|
||||
})
|
||||
.collect();
|
||||
let ordinary = d.add_object(dictionary! { "Subtype" => "Link", "Rect" => vec![0.into(),0.into(),50.into(),50.into()], "Dest" => vec![Object::Reference(page), Object::Name(b"Fit".to_vec())] });
|
||||
annots.push(Object::Reference(ordinary));
|
||||
d.objects.insert(page, dictionary! { "Type" => "Page", "Parent" => pages, "Annots" => annots }.into());
|
||||
d.objects.insert(
|
||||
pages,
|
||||
dictionary! { "Type" => "Pages", "Kids" => vec![page.into()], "Count" => 1, "MediaBox" => vec![0.into(),0.into(),200.into(),200.into()] }
|
||||
.into(),
|
||||
);
|
||||
let root = d.add_object(dictionary! { "Type" => "Catalog", "Pages" => pages });
|
||||
d.trailer.set("Root", root);
|
||||
let mut bytes = Vec::new();
|
||||
d.save_to(&mut bytes).unwrap();
|
||||
let info = inspect(Arc::new(bytes), None).unwrap();
|
||||
assert_eq!(info.links.len(), 34, "the resource limit must not drop annotations");
|
||||
assert!(info.has_javascript);
|
||||
let total: usize = info
|
||||
.links
|
||||
.iter()
|
||||
.map(|link| match &link.target {
|
||||
LinkTarget::JavaScript(source) => source.len(),
|
||||
_ => 0,
|
||||
})
|
||||
.sum();
|
||||
assert!(total <= 4 << 20, "shared action sources expanded to {total} bytes");
|
||||
assert!(matches!(&info.links[0].target, LinkTarget::JavaScript(_)));
|
||||
assert!(matches!(&info.links[32].target, LinkTarget::Other(kind) if kind == "JavaScript"));
|
||||
assert!(matches!(&info.links[33].target, LinkTarget::Page(0, _)), "ordinary links after the limit must still resolve");
|
||||
assert_eq!(info.warnings.iter().filter(|warning| warning.contains("4 MiB")).count(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn javascript_link_sources_direct_indirect_stream_and_utf16() {
|
||||
for storage in 0..7 {
|
||||
let mut d = Document::with_version("1.7");
|
||||
let pages = d.new_object_id();
|
||||
let source = "app.alert('é');";
|
||||
let string = if storage == 3 {
|
||||
{
|
||||
let mut bytes = vec![0xfe, 0xff];
|
||||
for unit in source.encode_utf16() {
|
||||
bytes.extend_from_slice(&unit.to_be_bytes());
|
||||
}
|
||||
Object::String(bytes, lopdf::StringFormat::Hexadecimal)
|
||||
}
|
||||
} else {
|
||||
Object::string_literal(source)
|
||||
};
|
||||
let js = match storage {
|
||||
1 => Object::Reference(d.add_object(string)),
|
||||
2 => {
|
||||
let mut stream = lopdf::Stream::new(dictionary! {}, source.as_bytes().to_vec());
|
||||
stream.compress().unwrap();
|
||||
Object::Reference(d.add_object(stream))
|
||||
}
|
||||
4 => {
|
||||
let cycle = d.new_object_id();
|
||||
d.objects.insert(cycle, Object::Reference(cycle));
|
||||
Object::Reference(cycle)
|
||||
}
|
||||
5 => {
|
||||
let mut stream = lopdf::Stream::new(dictionary! {}, vec![b'x'; 300 << 10]);
|
||||
stream.compress().unwrap();
|
||||
Object::Reference(d.add_object(stream))
|
||||
}
|
||||
6 => Object::Integer(42),
|
||||
_ => string,
|
||||
};
|
||||
let action = d.add_object(dictionary! { "S" => "JavaScript", "JS" => js });
|
||||
let annotation = d.add_object(
|
||||
dictionary! { "Type" => "Annot", "Subtype" => "Link", "Rect" => vec![0.into(),0.into(),50.into(),50.into()], "A" => action },
|
||||
);
|
||||
let page = d.add_object(dictionary! { "Type" => "Page", "Parent" => pages, "Annots" => vec![annotation.into()] });
|
||||
d.objects.insert(pages, dictionary!{ "Type" => "Pages", "Kids" => vec![page.into()], "Count" => 1, "MediaBox" => vec![0.into(),0.into(),200.into(),200.into()] }.into());
|
||||
let root = d.add_object(dictionary! { "Type" => "Catalog", "Pages" => pages });
|
||||
d.trailer.set("Root", root);
|
||||
let mut bytes = Vec::new();
|
||||
d.save_to(&mut bytes).unwrap();
|
||||
let info = inspect(Arc::new(bytes), None).unwrap();
|
||||
assert!(info.has_javascript);
|
||||
if storage >= 4 {
|
||||
assert!(matches!(&info.links[0].target, LinkTarget::Other(kind) if kind == "JavaScript"));
|
||||
continue;
|
||||
}
|
||||
let LinkTarget::JavaScript(script) = &info.links[0].target else { panic!("missing source") };
|
||||
assert_eq!(script, source);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1402,6 +1402,8 @@ struct PageEnv<'a> {
|
||||
struct PageOut {
|
||||
canvas_action: Option<comments::CanvasAction>,
|
||||
clicked_link: Option<LinkTarget>,
|
||||
/// The JavaScript link clicked, by page and annotation index (0-based), to run its action.
|
||||
javascript_link: Option<(usize, usize)>,
|
||||
comment_notice: Option<&'static str>,
|
||||
copy_comments: bool,
|
||||
copy_selected: bool,
|
||||
@@ -1964,6 +1966,7 @@ fn draw_pages(
|
||||
let PageOut {
|
||||
mut canvas_action,
|
||||
clicked_link,
|
||||
javascript_link,
|
||||
copy_selected,
|
||||
mut field_menu,
|
||||
field_placed,
|
||||
@@ -2214,6 +2217,7 @@ fn draw_pages(
|
||||
*out = PageOut {
|
||||
canvas_action,
|
||||
clicked_link,
|
||||
javascript_link,
|
||||
comment_notice,
|
||||
copy_comments,
|
||||
copy_selected,
|
||||
@@ -2908,6 +2912,8 @@ pub(crate) fn follow_link(
|
||||
LinkTarget::Uri(u) => app.request_document_url(&u, crate::LinkOrigin::Link),
|
||||
LinkTarget::SetLayers { changes, preserve_rb } => set_layers(app, index, &changes, preserve_rb),
|
||||
LinkTarget::RemoteGoTo { file, destination, .. } => return Some((view_id, file, destination)),
|
||||
// Run by `PdfCraftApp::run_link_script`, which needs the link's page and index.
|
||||
LinkTarget::JavaScript(_) => {}
|
||||
LinkTarget::Other(s) => app.notify_fmt("This PDF uses the unsupported {s} action.", &[("s", &s)]),
|
||||
}
|
||||
None
|
||||
@@ -3452,6 +3458,7 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) {
|
||||
let PageOut {
|
||||
canvas_action,
|
||||
clicked_link,
|
||||
javascript_link,
|
||||
comment_notice,
|
||||
copy_comments,
|
||||
mut copy_selected,
|
||||
@@ -3558,6 +3565,10 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) {
|
||||
}
|
||||
schedule_presentation_turn(ui, view, present, clicked_link.is_some() || on_object && present.is_some());
|
||||
let remote_open = follow_link(app, index, clicked_link);
|
||||
if let Some((page, link)) = javascript_link {
|
||||
let id = app.views[index].id;
|
||||
app.run_link_script(id, page, link);
|
||||
}
|
||||
if tool == QuickTool::Crop && cropped {
|
||||
tool = QuickTool::Select;
|
||||
}
|
||||
|
||||
@@ -886,6 +886,11 @@ pub(super) fn veiled(view: &DocView) -> bool {
|
||||
view.presentation.is_some() && !view.presenter.settled_on(view.current)
|
||||
}
|
||||
|
||||
/// The page and index of `l` when clicking it runs a JavaScript action (#2081).
|
||||
fn javascript_link(l: &pdfcraft_render::Link) -> Option<(usize, usize)> {
|
||||
matches!(l.target, LinkTarget::JavaScript(_)).then_some((l.page, l.index))
|
||||
}
|
||||
|
||||
/// Everything drawn over the page after its text: pictures, comments, content, links, fields, marks
|
||||
/// and badges.
|
||||
pub(super) fn page_overlays(ui: &mut egui::Ui, view: &mut DocView, frame: &mut PageFrame<'_>, slot: &PageSlot, input: &PageInput) {
|
||||
@@ -993,6 +998,7 @@ pub(super) fn page_overlays(ui: &mut egui::Ui, view: &mut DocView, frame: &mut P
|
||||
&& let Some(l) = info.links.iter().find(|l| l.page == i && l.index == index)
|
||||
{
|
||||
frame.out.clicked_link = Some(l.target.clone());
|
||||
frame.out.javascript_link = javascript_link(l);
|
||||
}
|
||||
// A web or email address written as plain text (#4207): the same click as a link annotation,
|
||||
// and the same check and confirmation on opening.
|
||||
|
||||
@@ -69,6 +69,18 @@ fn reply_to_prompt(ctx: &egui::Context, pending: &mut Pending, answer: Option<St
|
||||
impl PdfCraftApp {
|
||||
/// Start one explicit button interaction with captured document/view state and leave egui responsive.
|
||||
pub(crate) fn start_ui_script(&mut self, id: DocId, script: &str, field: Option<&str>, identity: Option<u64>) {
|
||||
self.start_script_with(id, |s| s.start_javascript_target(id, script, field, identity));
|
||||
}
|
||||
/// Start link `index` on page `page`'s JavaScript action (0-based) as a button's runs: its
|
||||
/// menus and questions are asked without blocking the window (#2081).
|
||||
pub(crate) fn start_link_script(&mut self, id: DocId, page: usize, index: usize) {
|
||||
self.start_script_with(id, |s| s.start_link_javascript(id, page, index));
|
||||
}
|
||||
fn start_script_with(
|
||||
&mut self,
|
||||
id: DocId,
|
||||
start: impl FnOnce(&mut pdfcraft_engine::Session) -> Result<PendingScript, pdfcraft_engine::EditError>,
|
||||
) {
|
||||
if self.script_pending.is_some() {
|
||||
self.notify("Another form script is active; this click was not run");
|
||||
return;
|
||||
@@ -76,7 +88,7 @@ impl PdfCraftApp {
|
||||
let Some(view) = self.views.iter().find(|v| v.id == id) else { return };
|
||||
let (zoom, page, highlight) = (view.zoom, view.current, view.highlight_fields);
|
||||
self.session.set_javascript_view(id, zoom as f64 * 100.0, highlight, page);
|
||||
match self.session.start_javascript_target(id, script, field, identity) {
|
||||
match start(&mut self.session) {
|
||||
Ok(run) => {
|
||||
self.script_pending =
|
||||
Some(Pending { run, prompt: None, text: String::new(), focus: false, started: Instant::now(), zoom, page, serial: 0 })
|
||||
@@ -410,3 +422,69 @@ mod review13_ui_tests {
|
||||
assert_eq!(view.forms.focus.as_ref().unwrap().name, "later");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod link_script_tests {
|
||||
use super::*;
|
||||
|
||||
/// A one-page PDF whose only annotation is a link running `script`, and a field `log`.
|
||||
fn linked(script: &str) -> Vec<u8> {
|
||||
let objs = [
|
||||
"<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [4 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 [6 0 R 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(),
|
||||
format!("<< /Type /Annot /Subtype /Link /Rect [20 20 180 60] /Border [0 0 0] /A << /S /JavaScript /JS ({script}) >> >>"),
|
||||
];
|
||||
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());
|
||||
pdf
|
||||
}
|
||||
|
||||
/// Clicking a JavaScript link runs its action without blocking: its popup menu is the
|
||||
/// window's prompt, the answer resumes it, and its field change and openDoc consent apply.
|
||||
#[test]
|
||||
fn a_javascript_link_asks_its_menu_and_applies_its_result() {
|
||||
// An absolute path on this system (the document has no folder): on Windows, Acrobat's
|
||||
// `/C/…` form, which reads as `C:/…`.
|
||||
let path = if cfg!(windows) { "/C/abs/other.pdf" } else { "/abs/other.pdf" };
|
||||
let script = format!("getField\\('log'\\).value = app.popUpMenuEx\\({{cName:'A', cReturn:'a'}}\\) + event.type; app.openDoc\\('{path}'\\);");
|
||||
let mut app = PdfCraftApp::new();
|
||||
app.open_bytes("link.pdf", None, linked(&script)).unwrap();
|
||||
let id = app.active_ids().unwrap().1;
|
||||
app.run_link_script(id, 0, 0);
|
||||
let ctx = egui::Context::default();
|
||||
for _ in 0..400 {
|
||||
app.poll_script_dialog(&ctx);
|
||||
if app.script_pending.as_ref().is_some_and(|p| p.prompt.is_some()) {
|
||||
break;
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(5));
|
||||
}
|
||||
let (prompt, reply) = app.script_pending.as_mut().unwrap().prompt.take().unwrap();
|
||||
assert!(matches!(&prompt, Prompt::Menu(items) if items.len() == 1 && items[0].value == "a"), "{prompt:?}");
|
||||
reply.send(Some("a".into())).unwrap();
|
||||
for _ in 0..400 {
|
||||
app.poll_script_dialog(&ctx);
|
||||
if app.script_pending.is_none() {
|
||||
break;
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(5));
|
||||
}
|
||||
assert!(app.script_pending.is_none());
|
||||
let log = app.session.get(id).unwrap().form.iter().find(|f| f.name == "log").unwrap().value.clone();
|
||||
assert_eq!(log, ["aLink"]);
|
||||
assert!(app.pending_remote_link.as_ref().is_some_and(|p| p.path.ends_with("other.pdf")), "openDoc waits for consent");
|
||||
}
|
||||
}
|
||||
@@ -133,6 +133,7 @@ fn target_label(info: &DocInfo, target: &LinkTarget) -> String {
|
||||
LinkTarget::Uri(u) => u.clone(),
|
||||
LinkTarget::SetLayers { .. } => tl!("Set layer visibility").to_string(),
|
||||
LinkTarget::RemoteGoTo { file, .. } => file.clone(),
|
||||
LinkTarget::JavaScript(_) => crate::i18n::fmt(tl!("{s} action"), &[("s", "JavaScript")]),
|
||||
LinkTarget::Other(s) => crate::i18n::fmt(tl!("{s} action"), &[("s", s)]),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -83,6 +83,7 @@ impl PdfCraftApp {
|
||||
self.views[i].go_to_page(p);
|
||||
}
|
||||
}
|
||||
Request::OpenDoc(path) => self.request_script_document(id, &path),
|
||||
Request::LaunchUrl(u) => self.request_document_url(&u, crate::LinkOrigin::Script),
|
||||
Request::Submit(u) => self.notify_fmt(
|
||||
"The form asks to be submitted to {u}; PdfCraft doesn't send form data. Save the document to keep your entries.",
|
||||
@@ -308,6 +309,67 @@ impl PdfCraftApp {
|
||||
}
|
||||
}
|
||||
|
||||
/// `app.openDoc(path)`: resolve the path against document `id`'s folder (no file is touched)
|
||||
/// and ask before opening it, as for a link to another PDF given by an absolute path; a script
|
||||
/// never opens a file unasked. A request while another link waits for an answer is dropped,
|
||||
/// as a link's is (#2081).
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn request_script_document(&mut self, id: DocId, path: &str) {
|
||||
if self.pending_remote_link.is_some() || self.pending_link.is_some() {
|
||||
return;
|
||||
}
|
||||
let source = self.session.get(id).and_then(|d| d.path.clone());
|
||||
match pdfcraft_engine::js::resolve_javascript_document_path(source.as_deref().map(std::path::Path::new), path) {
|
||||
Ok(path) => {
|
||||
self.pending_remote_link =
|
||||
Some(crate::PendingRemoteLink { source: id, path, destination: pdfcraft_render::RemoteDestination::Default })
|
||||
}
|
||||
Err(e) => self.notify_fmt("Couldn't follow linked PDF: {reason}", &[("reason", &e.to_string())]),
|
||||
}
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
fn request_script_document(&mut self, _id: DocId, _path: &str) {
|
||||
self.notify_tr("Local cross-PDF links require the desktop app");
|
||||
}
|
||||
|
||||
/// Run the JavaScript action of link `index` on page `page` (0-based; its place in the page's
|
||||
/// annotations), clicked in document `id` (#2081). A failed script changes nothing.
|
||||
pub fn run_link_script(&mut self, id: DocId, page: usize, index: usize) {
|
||||
if !self.commit_form_typing() {
|
||||
return;
|
||||
}
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
self.start_link_script(id, page, index);
|
||||
}
|
||||
// Script getters read the clicked view's zoom, page and field highlight, as on the
|
||||
// desktop, where starting the script sets them.
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
if let Some(view) = self.views.iter().find(|v| v.id == id) {
|
||||
let (zoom, current, highlight) = (view.zoom, view.current, view.highlight_fields);
|
||||
self.session.set_javascript_view(id, zoom as f64 * 100.0, highlight, current);
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
match self.session.run_link_javascript(id, page, index, &[]) {
|
||||
Ok(o) => {
|
||||
if 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);
|
||||
}
|
||||
let out = JsOutput {
|
||||
alerts: o.alerts,
|
||||
console: o.console,
|
||||
requests: o.requests,
|
||||
errors: o.error.into_iter().collect(),
|
||||
doc_errors: Vec::new(),
|
||||
};
|
||||
self.handle_js(id, out);
|
||||
}
|
||||
Err(e) => self.notify(e.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Prepare a form ▸ detect fields from the page's blanks, lines and boxes.
|
||||
pub fn detect_fields(&mut self) {
|
||||
let Some((i, id)) = self.active_ids() else { return };
|
||||
|
||||
@@ -83,6 +83,34 @@ host path cancels unanswered popups and response dialogs; no web-interaction par
|
||||
This work does not add timer execution, PDF annotations/layers to the JS object model, or the
|
||||
remaining document action triggers.
|
||||
|
||||
## Link actions
|
||||
|
||||
A link annotation whose action is JavaScript (`/S /JavaScript`) runs it when clicked. The script
|
||||
runs in the document's realm, so document scripts and earlier button or field scripts are in
|
||||
scope. `event.type` is `Link`, `event.name` is `Mouse Up`, `event.target` is the document
|
||||
(`this`; not enumerable on the event, so `JSON.stringify(event)` works), and `this.pageNum` is
|
||||
the link's page. Its menus, questions and alerts use the same worker prompts as a button's. A
|
||||
script that fails commits nothing: its field changes and requests are dropped, and what it
|
||||
printed to the console and its error are kept. A read-only document's links run too, but a
|
||||
field change there fails to commit as any edit does. The link's tip reads "JavaScript action".
|
||||
A script is read up to 256 KiB per link and 4 MiB per document. Longer ones are rejected and
|
||||
reported once.
|
||||
|
||||
`app.openDoc(cPath)` and `app.openDoc({cPath, oDoc, cFS})` only queue a request, then return
|
||||
null. The base may only be this document, and only the local file system (`cFS` empty) is
|
||||
supported; other options are refused with a script error. The path resolves against the
|
||||
document's folder as a link to another PDF does (`/C/dir/file.pdf` reads as `C:/dir/file.pdf`
|
||||
on Windows). The viewer always asks before opening it. A request that arrives while another
|
||||
link is waiting for an answer is dropped. Field scripts (format, keystroke, validate,
|
||||
calculate) may not open documents; the request is noted on the console. The web build says
|
||||
local cross-PDF links need the desktop app. A popup menu with no items returns null without
|
||||
asking, but it still counts toward the 32-prompt limit.
|
||||
|
||||
`link_run_javascript` (doc, 1-based page and annotation index from `link_list`, and optional
|
||||
ordered `answers`) runs a link's action headlessly. It returns alerts, console output,
|
||||
requests (including `open_doc`) and the error, and never carries requests out. `doc_info`
|
||||
shows such links as `{ "javascript": … }`.
|
||||
|
||||
## Page actions, timers and layers
|
||||
|
||||
When the page being read changes (the active tab's current page), the close actions of the
|
||||
|
||||
Reference in new issue
Block a user