mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-11-14 06:42:20 +00:00
engine: run a link's JavaScript action in the document realm
Ported from #2081: Session::run_link_javascript / start_link_javascript run a link's action as event Link/Mouse Up on its page, with the document as target, in the per-document ScriptSession; a failed link commits no field change or request; read-only documents' links run too. Field scripts may not open another document; resolve_javascript_document_path resolves openDoc paths as remote-link paths (Acrobat /C/... paths on Windows). Co-authored-by: LMatt08 <245691047+LMatt08@users.noreply.github.com>
This commit is contained in:
1 parent
d9c895a32f
commit
52ca67c8c4
3 files changed
+250
-2
No files matched your search
+108
-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()),
|
||||
}
|
||||
}
|
||||
@@ -1448,9 +1451,100 @@ mod caption_tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// A failed link action commits nothing: its field changes and requests are dropped, 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();
|
||||
}
|
||||
}
|
||||
|
||||
impl Session {
|
||||
/// 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;
|
||||
let fields = pdfcraft_forms::fields(cos);
|
||||
let states = std::sync::Arc::new(fields.iter().map(|f| field_state(cos, f, &runner.doc)).collect::<Vec<_>>());
|
||||
let 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),
|
||||
};
|
||||
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.
|
||||
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);
|
||||
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>>,
|
||||
@@ -1498,7 +1592,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) {
|
||||
@@ -1520,7 +1623,10 @@ 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()));
|
||||
|
||||
@@ -200,5 +200,7 @@ fn display_config(config: &pdfcraft_render::RenderConfig) -> pdfcraft_render::Re
|
||||
/// How long a custom stamp's text line may be, in characters (#1741).
|
||||
pub const MAX_STAMP_LINE: usize = 200;
|
||||
|
||||
#[cfg(test)]
|
||||
mod link_javascript_tests;
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -0,0 +1,140 @@
|
||||
//! 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> {
|
||||
pdf_of(&[
|
||||
"<< /Type /Catalog /Pages 2 0 R /Names << /JavaScript << /Names [(init) 8 0 R] >> >> /AcroForm << /Fields [4 0 R] /DA (/Helv 0 Tf 0 g) /DR << /Font << /Helv 5 0 R >> >> >> >>".into(),
|
||||
"<< /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(),
|
||||
"<< /Type /Annot /Subtype /Widget /FT /Tx /T (log) /V () /Rect [20 250 180 270] /P 3 0 R /F 4 >>".into(),
|
||||
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>".into(),
|
||||
"<< /Type /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(),
|
||||
])
|
||||
}
|
||||
|
||||
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) {
|
||||
let mut s = Session::new();
|
||||
let id = s.open("link.pdf", None, Arc::new(linked(script)), 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:?}");
|
||||
}
|
||||
Reference in new issue
Block a user