mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-11-15 07:12:52 +00:00
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:
1 parent
e47e9fbc5e
commit
c6646e3469
5 files changed
+146
-1
No files matched your search
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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.",
|
||||
@@ -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 };
|
||||
|
||||
Reference in new issue
Block a user