From c6646e3469fda9e2d50d8966b56c3b53bed8b2e1 Mon Sep 17 00:00:00 2001 From: FredApps <153173854+FredApps@users.noreply.github.com> Date: Wed, 11 Nov 2026 13:07:19 +0100 Subject: [PATCH] ui: clicking a JavaScript link runs its action; openDoc asks first Ported from #2081 onto main's script prompt UI: a JavaScript link click starts the link's action like a button's (menus and questions without blocking the window; a failed one changes nothing); its tip reads "JavaScript action"; app.openDoc queues the existing consent prompt for another PDF (web: unavailable). No new strings. Co-authored-by: LMatt08 <245691047+LMatt08@users.noreply.github.com> --- crates/ui-egui/src/canvas.rs | 11 ++++ crates/ui-egui/src/canvas/page_input.rs | 3 + crates/ui-egui/src/script_dialog.rs | 77 ++++++++++++++++++++++++- crates/ui-egui/src/tools/link_ui.rs | 1 + crates/ui-egui/src/workflows/js_ui.rs | 55 ++++++++++++++++++ 5 files changed, 146 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 90840225ef..015ed58c36 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -1402,6 +1402,8 @@ struct PageEnv<'a> { struct PageOut { canvas_action: Option, clicked_link: Option, + /// 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, @@ -1959,6 +1961,7 @@ fn draw_pages( let PageOut { mut canvas_action, clicked_link, + javascript_link, copy_selected, mut field_menu, field_placed, @@ -2209,6 +2212,7 @@ fn draw_pages( *out = PageOut { canvas_action, clicked_link, + javascript_link, comment_notice, copy_comments, copy_selected, @@ -2903,6 +2907,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 @@ -3447,6 +3453,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, @@ -3553,6 +3560,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; } diff --git a/crates/ui-egui/src/canvas/page_input.rs b/crates/ui-egui/src/canvas/page_input.rs index b39c16fde0..e171515569 100644 --- a/crates/ui-egui/src/canvas/page_input.rs +++ b/crates/ui-egui/src/canvas/page_input.rs @@ -986,6 +986,9 @@ 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()); + if matches!(l.target, LinkTarget::JavaScript(_)) { + frame.out.javascript_link = Some((l.page, l.index)); + } } // 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. diff --git a/crates/ui-egui/src/script_dialog.rs b/crates/ui-egui/src/script_dialog.rs index 2249f7bfd7..c7ab61e5a5 100644 --- a/crates/ui-egui/src/script_dialog.rs +++ b/crates/ui-egui/src/script_dialog.rs @@ -69,6 +69,18 @@ fn reply_to_prompt(ctx: &egui::Context, pending: &mut Pending, answer: Option, identity: Option) { + 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, + ) { 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,66 @@ 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 { + 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() { + let script = "getField\\('log'\\).value = app.popUpMenuEx\\({cName:'A', cReturn:'a'}\\) + event.type; app.openDoc\\('/abs/other.pdf'\\);"; + 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"); + } +} diff --git a/crates/ui-egui/src/tools/link_ui.rs b/crates/ui-egui/src/tools/link_ui.rs index 8237fa0cf3..0ed184a44d 100644 --- a/crates/ui-egui/src/tools/link_ui.rs +++ b/crates/ui-egui/src/tools/link_ui.rs @@ -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)]), } } diff --git a/crates/ui-egui/src/workflows/js_ui.rs b/crates/ui-egui/src/workflows/js_ui.rs index 3547c511f2..eddd155fc7 100644 --- a/crates/ui-egui/src/workflows/js_ui.rs +++ b/crates/ui-egui/src/workflows/js_ui.rs @@ -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.", @@ -235,6 +236,60 @@ 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); + } + #[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 };