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>
This commit is contained in:
FredAppsandLMatt08 committed 2026-11-11 13:07:19 +01:00
1 parent e47e9fbc5e
commit c6646e3469
5 files changed
+146 -1

No files matched your search

+11
View File
@@ -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,
@@ -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;
}
+3
View File
@@ -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.
+76 -1
View File
@@ -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,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<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() {
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");
}
}
+1
View File
@@ -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)]),
}
}
+55
View File
@@ -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 };